Secure Email Verification Links: Tokens, Expiry, and Replay
A secure email verification link needs five properties: a cryptographically random token, hashed storage, one narrow purpose, an expiry, and atomic single-use redemption.
The account stays pending until the server consumes the token. A second request with the same link must find nothing left to consume.
Email confirmation proves someone with access to the inbox completed one challenge. It does not prove legal identity, permanent mailbox ownership, future delivery, or a safe signup.
What state should the server keep?
I treat a verification link as a pending database transition, not as a URL with authority forever.
| State | Meaning | Allowed transition |
|---|---|---|
pending |
The digest exists, the deadline has not passed, and no redemption succeeded. | Move to consumed once. |
consumed |
One request completed the intended confirmation. | None. Later requests get a neutral result. |
expired |
The deadline passed before redemption. | Issue a new challenge after a rate-limited request. |
superseded |
A newer challenge replaced this one. | None. |
This model gives replay, resend, and expiry explicit outcomes. It also keeps confirmation separate from address evidence, as described in Email Validation vs Verification vs Confirmation.
What belongs in the token record?
The URL should carry only the opaque token. Everything with business meaning stays on the server.
| Field | Reason |
|---|---|
token_digest |
Looks up the challenge without retaining the bearer token. |
user_id |
Binds redemption to one stable account record. |
email_snapshot |
Records the exact address challenged at issuance. |
purpose |
Stops a signup token from authorizing a password reset or email change. |
expires_at |
Limits the bearer token’s useful lifetime. |
consumed_at |
Makes one successful redemption observable and final. |
OWASP recommends secure random tokens, single use, a time limit, delayed activation, and no raw verification tokens or full links in logs. Its email ownership guidance states the baseline directly.
Do not accept an email address from the redemption request and attach it to the token. Reload the server-side snapshot. The token authorizes one recorded transition, not caller-supplied data.
How do you issue an opaque token?
This Node.js example generates 32 random bytes, sends the base64url value, and stores only its SHA-256 digest. The raw token exists long enough to build the message, then leaves application memory.
import { createHash, randomBytes } from 'node:crypto'
function tokenDigest(token) {
return createHash('sha256').update(token, 'utf8').digest('hex')
}
export async function issueEmailVerification(db, user) {
const token = randomBytes(32).toString('base64url')
const digest = tokenDigest(token)
const expiresAt = new Date(Date.now() + 8 * 60 * 60 * 1000)
await db.transaction(async tx => {
await tx.query(
`UPDATE email_verification_tokens
SET superseded_at = NOW()
WHERE user_id = $1
AND purpose = 'signup'
AND consumed_at IS NULL
AND superseded_at IS NULL`,
[user.id]
)
await tx.query(
`INSERT INTO email_verification_tokens
(token_digest, user_id, email_snapshot, purpose, expires_at)
VALUES ($1, $2, $3, 'signup', $4)`,
[digest, user.id, user.email, expiresAt]
)
})
const url = new URL('https://app.example/confirm-email')
url.searchParams.set('token', token)
return { url: url.toString(), expiresAt }
}
The eight-hour deadline is a policy example, not a universal standard. OWASP’s input-validation guidance gives eight hours as an example and calls for at least 32 characters. Choose a shorter or longer window from the signup risk and resend experience.
Node’s crypto documentation defines randomBytes(). Do not substitute Math.random(), a timestamp, an email hash, or a sequential ID.
How do you stop two redemptions from winning?
A read followed by an update is not enough. Two requests might both read “unused” before either writes consumed_at.
Put every eligibility condition in one UPDATE. Treat a returned row as the only success signal.
UPDATE email_verification_tokens
SET consumed_at = NOW()
WHERE token_digest = $1
AND purpose = 'signup'
AND consumed_at IS NULL
AND superseded_at IS NULL
AND expires_at > NOW()
RETURNING user_id, email_snapshot;
PostgreSQL’s UPDATE ... RETURNING documentation explains why only rows updated by the statement appear in the result. Under concurrent requests, one update claims the pending row. The other returns zero rows.
I ran the same conditional-update rule in a fresh local SQLite fixture on August 28, 2026. A 32-byte token produced 43 base64url characters, its SHA-256 digest produced 64 hexadecimal characters, the first redemption changed one row, and the replay changed zero.
After the claim, update the account and record the audit event inside the same transaction. A failure should roll back both the token consumption and account activation.
const digest = tokenDigest(request.query.token ?? '')
await db.transaction(async tx => {
const challenge = await tx.oneOrNone(CONSUME_SQL, [digest])
if (!challenge) {
throw new VerificationLinkUnavailable()
}
await tx.query(
`UPDATE users
SET email_confirmed_at = NOW()
WHERE id = $1
AND email = $2
AND email_confirmed_at IS NULL`,
[challenge.user_id, challenge.email_snapshot]
)
})
Return the same public message for expired, superseded, unknown, and previously consumed tokens. A resend action belongs on the same screen, with per-account and per-address rate limits. This avoids turning the endpoint into an account-enumeration oracle. The deeper response-shape issue is covered in Email Enumeration Attacks.
Is a signed JWT enough?
The strongest objection is fair: a signed JWT already protects its claims from tampering, so why keep a database row?
A valid signature says the issuer signed the token. It does not mark the token used. It also does not invalidate an older token after a resend unless the server tracks extra state.
You still need replay control, purpose binding, expiry checks, and a way to supersede earlier challenges. At that point, an opaque token plus one database row is easier for me to audit. OWASP’s token guidance notes extra JWT vulnerability risk and still requires secure storage and invalidation after use.
What should the link endpoint protect?
- Build the URL from a fixed HTTPS origin. Never trust a request
Hostheader while assembling outbound links. - Set
Referrer-Policy: no-referreron the confirmation response so the query token does not travel to another origin in a referrer. - Remove the token from the visible URL after redemption, then show the outcome at a clean path.
- Keep raw tokens and full verification URLs out of logs, analytics events, error trackers, and support screenshots.
- Rate limit challenge issuance and redemption attempts without locking an account because of hostile traffic.
- Require normal authentication after signup confirmation instead of treating the link as a durable login session.
The OWASP URL-token flow supports the fixed origin, HTTPS, referrer policy, rate limiting, and return-to-login controls. Password reset carries higher authority than signup confirmation, but bearer-token leakage and replay have the same shape.
Where does address verification fit?
Address verification belongs before token issuance when a failed delivery would waste effort or a signup policy needs routing and risk evidence. Confirmation still owns the inbox-control decision.
The emailverifier.dev API returns deliverable, risky, undeliverable, and unknown outcomes with an allow, review, or block action. A confirmed syntax or routing failure supports asking for a correction. A disposable or role-address signal needs your product policy. An unknown result stays inconclusive.
Do not turn a risky signal into proof of abuse. Do not turn SMTP acceptance into proof of ownership. Do not turn an unknown result into “invalid.”
A confirmation link records one completed inbox challenge. The token row makes the claim narrow, expiring, and single-use. The atomic update makes it true under replay.