Email from a server that can't send it, and a password reset that lands in the right app
Blocked SMTP ports, a sending domain in four DNS records, and why the reset is a six-digit code instead of a link
Until 20 September none of my apps had a way back in. Forget your password on the gym tracker and the answer was "ask Omar". That is fine for three friends and wrong for anything I would sell.
The reset itself turned out to be the easy half. The hard half was the two things around it: getting mail out of a server that is not allowed to send any, and making sure the reset finishes inside the app on the phone, not in some other browser. Both were decided by facts I only found by testing. Almost nothing here is what I would have guessed on the first day.
What this covers
- Why the droplet cannot send mail itself, measured rather than assumed
- Brevo's HTTP API as the only mail path, as
[email protected] - Authenticating a sending domain: DKIM, DMARC, and the SPF record you must not add
- A reset that sends a code, not a link — and the iOS behaviour that forces it
- The properties that stop a reset from leaking who has an account
- Two deploy traps that cost an hour each
- Building it a second time, and what actually transferred
The shape of it
phone (installed app) droplet
───────────────────── ───────
Forgot password ── POST /auth/forgot ──▶ API ── hash code, store row
│ │
│ └── HTTPS ──▶ Brevo ──▶ inbox
│ │
│ ◀────────────── person reads "418 392" ──────────────────┘
│
types code + new password ── POST /auth/reset ──▶ API
│ ├── delete every session
◀──────── new session cookie ────────┘── start one for this device
Two things to notice. Mail leaves over HTTPS, not SMTP. And the loop closes in the same app that opened it — the email carries something a person reads, not something they tap.
The droplet cannot send mail
The obvious plan was the one every tutorial gives: install Postfix, or point
nodemailer at an SMTP server, done. EpisodeTracker already sent its daily show emails
through Gmail's SMTP on my Mac, so I assumed the same code would work on the server.
It did not, and before building anything I checked why:
| Port | What it is for | From the droplet |
|---|---|---|
| 25 | server-to-server mail | times out |
| 465 | SMTP over TLS | times out |
| 587 | SMTP submission | times out |
| 443 | HTTPS | fine |
for p in 25 465 587; do
nc -vz -w 5 smtp.gmail.com $p 2>&1 | tail -1
done
# three timeouts — not "refused", which would mean something answered
A timeout, not a refusal. The packets never leave the machine: the provider blocks outbound SMTP on new accounts, which is common on cheap VPS plans because it is how spam gets sent. Installing a mail server changes nothing — the mail server would hit the same wall.
Even with the ports open, self-hosted mail would not arrive. The droplet's reverse DNS
(what its IP says its own name is) is a .local placeholder, and Gmail rejects mail
from an address whose reverse DNS does not match the sender. Fixing that means asking
the provider, then spending months building a sending reputation from zero.
Don't read "own server, no hosted backend" as "so host your own mail". Everything else on this box is mine. Mail is the settled exception: outgoing mail needs a sender with a reputation, and that is the one thing a $6 droplet cannot have.
Brevo, over HTTPS
HTTPS is open, so the answer is a mail provider with an HTTP API. I chose Brevo: EpisodeTracker already used it for its show alerts, the free tier is about 300 emails a day, and these apps send a handful.
The whole integration is one fetch:
// api/src/email/mailer.ts (gym tracker)
const res = await fetch("https://api.brevo.com/v3/smtp/email", {
method: "POST",
headers: { "api-key": apiKey, "content-type": "application/json", accept: "application/json" },
body: JSON.stringify({
sender: { name: from.name, email: from.email },
to: [{ email: msg.to }],
subject: msg.subject,
textContent: msg.text,
...(msg.html ? { htmlContent: msg.html } : {}),
}),
// Brevo is a third party on the request path of a user waiting for a
// code; never let it hang the worker.
signal: AbortSignal.timeout(10_000),
});
No SDK, no nodemailer. And a fallback that matters more than it looks:
export function getEmailer(): Emailer {
const key = brevoApiKey();
return key ? brevoEmailer(key) : consoleEmailer;
}
With no BREVO_API_KEY, the message is printed to the log instead of sent. Local
development and the test suite need no account, send nothing, and cannot accidentally
email a real address.
A new app on this server needs exactly two environment variables: the API key, and who the mail is from.
Making the mail yours: authenticating the domain
Out of the box, Brevo sends "from" an address you have verified — at first a Gmail address. Mail from a Gmail address that did not come through Gmail's own servers is exactly what spam filters look for. It also says nothing about oyware.
So oyware.com became an authenticated sending domain. Then any address on it may
send — [email protected] for all the apps — and every message is signed by the
domain itself.
The records, in Cloudflare:
| Record | Type | What it does |
|---|---|---|
brevo1._domainkey |
CNAME | DKIM key 1 — lets receivers check the signature |
brevo2._domainkey |
CNAME | DKIM key 2 — so Brevo can rotate keys without you |
@ (apex) |
TXT brevo-code:… |
proves to Brevo that you control the domain |
_dmarc |
TXT v=DMARC1; p=none; … |
tells receivers what to do when checks fail |
The DKIM records must be DNS only
Cloudflare's default for a new CNAME is proxied — the orange cloud. For a website, that is the point. For a DKIM record it breaks everything: a proxied CNAME answers with Cloudflare's own addresses instead of pointing at Brevo's key, so receivers look up the signature key and get nothing usable.
Set both _domainkey CNAMEs to DNS only (grey cloud). Anything that is not a web
server your visitors connect to — mail records, verification records — should never be
proxied.
Do not add a second SPF record
Brevo's setup screens talk about SPF, and the reflex is to add
v=spf1 include:… ~all for Brevo. Don't.
oyware.com already has one SPF record, for Cloudflare's email routing (which forwards
[email protected] to my inbox):
v=spf1 include:_spf.mx.cloudflare.net ~all
The SPF rule is that a domain has exactly one v=spf1 record. Two is not "both
apply" — two is a permanent error, and receivers treat SPF as failed for every sender
on the domain, including the forwarding that was working a minute ago.
Brevo does not need an SPF include here at all. SPF checks the envelope sender, which
is Brevo's own bounce domain, not yours. Alignment for DMARC comes from the DKIM
signature. So the brevo-code TXT goes at the apex beside the SPF record, and the
SPF record is left exactly as it was.
DMARC at p=none
p=none means "report, don't reject". It is the right start: DMARC is present (Gmail
and Yahoo now expect it from bulk senders), and nothing legitimate can be bounced by a
mistake while you are still learning what sends mail as your domain. Tighten it later.
How to know it worked
Send a real message to a Gmail inbox, open it, and look at the details under the sender:
from: Gym Tracker <[email protected]>
signed-by: oyware.com
signed-by: oyware.com is the line that matters. It landed in the Inbox, not Spam,
on the first try.
Brevo's setup can all be done over its API instead of its dashboard —
/v3/senders/domains to add the domain and read back the records it wants,
PUT /v3/senders/domains/<domain>/authenticate to have it check them, /v3/senders for
the sender. I ran those from the droplet, so the API key never left the server.
Cloudflare's dashboard reads fine and refuses writes. I tried to add the records through the dashboard's internal API from a signed-in tab: GETs work, POSTs get a WAF 403. Read with the API, write through the UI. And when pasting a long value into a field — a DKIM target, a TXT string — read the field back before saving: on the first try the value was cut short.
A code, not a link
Every reset I had used as a customer sends a link. So the first design I wrote down was a link: click it, land on a "choose a new password" page, done. I recommended it.
Then the question was asked back: why a link? And following the answer through showed it would fail for most of the people using these apps.
The apps are installed. The gym tracker is a PWA that lives on the home screen; so is EpisodeTracker. On iOS, a home-screen web app runs in its own storage container, separate from Safari's — its own cookies, its own logins — even though it is the same domain.
Now follow the link:
installed app: "Forgot password" → email arrives → tap the link
│
iOS opens it in SAFARI, not the app
│
reset succeeds, login cookie is set… in Safari
│
back to the app icon: still logged out, holding a password
they chose thirty seconds ago
Gmail's in-app browser splits the same way. The reset works and the person is still locked out of the thing they were trying to open.
A code does not have this problem. The email says 418 392; the person switches back to
the app and types it; the reset finishes in the app that asked for it, and that app
gets the new session.
| The argument for a link | Why it loses here |
|---|---|
| One tap is better UX | One tap into the wrong browser is a dead end |
| Codes get mistyped | A typo costs a retype; the split costs being locked out |
| It's the same origin, so the app is fine | The split is not about origin — iOS gives the installed app its own cookie jar for the same origin |
| The link will open the app | On Android, sometimes. On iOS it opens Safari |
Code-only is also less code than a link: no token route, no reset page that can 404, no "this link has expired" screen, no "already used" state. If desktop users ever matter enough, a link can go beside the code. Never instead of it.
The email puts the code in the subject too — 418392 is your Gym Tracker reset code —
so it can be read off the notification without opening anything. In the body the digits
are grouped in threes, because that is what a person can hold in their head between two
apps. The
input strips everything that is not a digit, so a code pasted as 418 392 still works.
The rules that make a reset safe
A password reset is a login that bypasses the password, so it gets the same care. Most of these are standard. Two of them are not obvious, and both are about not telling a stranger who has an account.
The data
One row per user:
model PasswordReset {
userId String @id
// argon2 of the six digits. Six digits is little entropy, so it gets the slow
// hash and `attempts` below, not a plain digest.
codeHash String
expiresAt DateTime
attempts Int @default(0)
// Codes sent in the window that began at windowStartedAt, so a forgotten
// password cannot be turned into a flood of mail to someone else's inbox.
sentCount Int @default(1)
windowStartedAt DateTime @default(now())
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
Keyed on userId, so asking again replaces the code. There is never a second one
still working.
| Rule | Value | Why |
|---|---|---|
| Code | 6 digits, crypto.randomInt |
Math.random() * n and randomBytes % n both skew the distribution |
| Stored as | the app's password hash (argon2 / scrypt) | six digits is a million guesses; a fast hash would fall in seconds offline |
| Lives | 30 minutes | long enough to find the mail, short enough that a leaked inbox goes stale |
| Wrong tries | 5, then the code is deleted | 5 in a million is the whole budget an attacker gets |
| Codes per address | 3 per hour | so nobody can use your form to flood someone else's inbox |
| Endpoints | rate-limited per IP, like login | the same limiter the rest of /auth/ uses |
Always answer "ok"
POST /auth/forgot answers { ok: true } whether the address has an account or not,
and whether a mail was sent or the hourly allowance was already used up. Anything else —
"no account with that email" — turns the form into a lookup service for who uses the app.
Hash before you look
This is the part that is easy to get wrong while believing you got it right. The natural code is:
const user = await findUser(email);
if (!user) return { ok: true }; // fast
const codeHash = await argon2(code); // slow — ~tens of ms
The answer is identical, but the time is not. An unknown address returns immediately; a real one spends a slow hash first. Measure the response time and you have the same lookup service back.
So the slow work happens first, for everybody:
app.post("/auth/forgot", limited, async (request) => {
const body = parse(forgotBody, request.body);
const code = newCode();
const codeHash = await hashPassword(code); // before the lookup, always
const user = await prisma.user.findUnique({ where: { email: body.email } });
const ok: { ok: true; devCode?: string } = { ok: true };
if (!user) return ok;
// …
});
The reset side does the same: an unknown address still runs a real hash check against nothing before failing.
One message for every failure
Unknown address, no code requested, expired, wrong digits, out of tries — all five get the same answer:
That code is wrong or has expired. Ask for a new one.
They mean the same thing to the person (ask again), and distinguishing them would tell a stranger which of those states an address is in.
Send after answering
void getEmailer()
.send({ to: body.email, ...message })
.catch((err) => request.log.error({ err }, "reset email was not sent"));
Not awaited. The person is waiting on this request; Brevo is not on their critical path, and the code is already valid either way. A send failure is mine to read in the log, not theirs to decode.
On success, end everything — then log them in
const [updated] = await prisma.$transaction([
prisma.user.update({ where: { id: user.id }, data: { passwordHash } }),
prisma.authSession.deleteMany({ where: { userId: user.id } }),
]);
await startSession(reply, user.id);
Every session that account had is deleted. Resetting a password is how you throw out someone who got in — a password change that leaves their session alive changes nothing.
And then this device gets a fresh session. The person is standing in the app, having just typed a new password. Sending them to the login screen to type it again is a small insult, and on a phone a real chance to typo it and start over.
The race I shipped, and found five days later
The first version checked attempts like this: read the row, run the slow hash, write
attempts + 1. It passed every test.
On 25 September a code review of the whole app asked what happens if the guesses arrive
at the same time. Ten requests fired together all read attempts = 0, all spend
their argon2 time, all write back 1. Ten guesses, one try. The limit of five was
really "five rounds of as many parallel guesses as you like".
The fix is to spend the try before checking the code, in one conditional write:
// Spend a try before checking the code, in one conditional write. Reading
// the count and writing it back after the slow hash let parallel guesses all
// read 0, so ten guesses cost one try.
const taken = await prisma.passwordReset.updateMany({
where: { userId: user.id, attempts: { lt: MAX_ATTEMPTS }, expiresAt: { gt: now } },
data: { attempts: { increment: 1 } },
});
The database does the check and the increment as one step, so the sixth parallel request finds nothing to update. And the same idea claims the reset itself: the request that deletes the row owns it, so the right code sent twice at once sets one password, not two.
const used = await prisma.passwordReset.deleteMany({
where: { userId: user.id, codeHash: reset.codeHash },
});
if (used.count === 0) throw wrong;
Anything shaped read → slow work → write back is a race. If the check and the update can be one conditional statement, make them one.
Testing without an inbox
Tests and local development have no mailbox to read the code from. So outside
production, /auth/forgot returns the code in its answer:
if (!isProduction()) ok.devCode = code;
return ok;
The web page pre-fills the code field from it, and the browser flow reads it from there.
That is safe only if production can never do it — so there is a test that asserts the
production response carries no devCode. After the deploy I checked the live response by
hand as well.
The browser flow (headless Chrome at phone width) walks the whole path: the address typed on the login screen carries over to Forgot password, a wrong code is refused without losing the screen, the right one logs in, the old password is dead, the new one works, and the same code is refused a second time.
It refuses to run against the live host — it changes a password:
if (/tracker\.oyware\.com/.test(ORIGIN)) {
console.error("Refusing to run against production: this flow changes a password.");
process.exit(1);
}
Production got one manual run instead, with a throwaway account on a plus-address of my
own inbox: the code arrived in the Inbox, from [email protected], signed by
oyware.com. Then the account was deleted.
Two deploy traps
< in an env file is a redirect
The sender goes in .env as a name and an address:
EMAIL_FROM=Gym Tracker <[email protected]>
The deploy script loads that file with bash — set -a && . ./.env && set +a — and to
bash, that line is a command: run Tracker with EMAIL_FROM=Gym set, reading input from
a file called [email protected]. The deploy died partway through, after the build
and before the database migration, with an error about a missing file that looks
nothing like an email problem.
EMAIL_FROM="Gym Tracker <[email protected]>"
Quote any value in a sourced .env that contains <, >, spaces or quotes. The
example file now says so on the line above.
A new screen needs a server route too
EpisodeTracker's server only hands the React app to paths it knows about. /forgot
worked when you reached it by tapping "Forgot password?" — in-app navigation never asks
the server — and would have 404'd on a refresh, a bookmark, or a shared link. A test
that compares the React routes with the server's list caught it before deploy.
curlon an SPA route returns 404 without-H 'Accept: text/html'. That looks exactly like the bug above and is not it. Check/loginthe same way: if it 404s too, it is the header.
Building it twice
The gym tracker got the reset first, at 23:03 on the 20th. EpisodeTracker got the same
flow forty minutes later, and moved its daily show emails onto [email protected] at the
same time — so they are now signed by the domain instead of coming from a Gmail sender.
Almost none of the code carried over:
| Gym tracker | EpisodeTracker | |
|---|---|---|
| Password hash | argon2 | scrypt (Node built-in) |
| Validation | zod | Fastify JSON schema |
| Tests | API tests against a real test database | plain unit tests, rules in a pure module |
| Styling | Tailwind + shadcn | hand-written CSS (at the time) |
| Where routes are known | Nginx try_files |
a server-side route list |
All of the decisions did: code not link, the limits, hash-before-lookup, one error
message, end every session and log in here, devCode with a test against it, Brevo as the
only mail path, the quoting trap.
So the decisions became a reusable skill — a checklist an agent loads when a new site on this server needs a way back in — rather than a shared library. A library would have fought each app's hash, CSS and test harness. The checklist fits all of them, and the third site that needs a reset starts from every answer in this guide instead of rediscovering it.
What I would tell myself on the 19th
- Test the ports before choosing the mail path. Three
nccommands would have ruled out SMTP in ten seconds. - One SPF record per domain. Add a sender's include only if it actually needs one; most HTTP mail providers don't.
- Ask where the reset lands, not just whether it works. On an installed app, a link finishes in the wrong place.
- Timing is an answer. Equal responses are not enough if one of them is faster.
- Limits that read, wait, then write are not limits under parallel load.