Skip to content

Checking access...

Pattern: One-Time Confirmation Tokens

Status: Stable Reference implementation: ii_email_challenge_tokens (AUTH-008, 2026-04-18) Use when: A privileged flow needs a short-lived, session-bound, single-use token to gate a proof-of-ownership step — e.g. linking an Internet Identity to an existing email/password account, changing a user's email, attaching a passkey, or stepping up to perform a sensitive action.

This pattern is the canonical template. The next flow that needs one of these tokens should copy the shape verbatim, only changing the table name, the context columns (e.g. existing_user_id, email_hash), and the expiry interval.


Threat Model

The pattern protects against four attacker behaviors:

ThreatDefense
Replay — attacker reuses a leaked or observed token from logs, browser history, or a prior responseSingle-use atomic consume (used_at IS NULL check inside the same UPDATE that sets used_at = NOW()), UNIQUE constraint on token_hash
Session takeover / token lift — token issued for session A is replayed from session B (e.g. the attacker grabs the token from the victim's clipboard or a screenshot and uses it from their own browser session)session_id bound at creation time; atomic consume requires session_id = $2 in the UPDATE's WHERE clause, so the burn AND the binding check succeed-or-fail together
Info leak on nonexistence / oracle attack — attacker probes whether a token is "expired" vs "wrong session" vs "already used" to learn something about the victimAll four failure modes (doesn't exist / expired / already used / wrong session) return the same 410 body; internal reason is audit-logged but never returned in the HTTP response
Token leak at rest — attacker gains DB read access and lifts raw tokens to replay in flightTokens are never stored in plaintext: only SHA-256(raw_token) lives in the DB. Raw token is returned to the client once, at creation time, and is reconstructed via hash comparison on consume

The pattern does not protect against:

  • An attacker who already has a valid session cookie for the victim (use CSRF tokens + same-site cookies for that)
  • An attacker with write access to the application DB (they can forge any token)
  • Brute force of the raw token value (32-byte CSPRNG makes this intractable — do not lower)

Schema Requirements

All six requirements below are load-bearing. Drop any one and the threat model breaks.

sql
CREATE TABLE IF NOT EXISTS <flow_name>_tokens (
  id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),

  -- (1) Session binding: token can only be consumed from the session that
  -- was active when it was issued. CASCADE so a logout invalidates all
  -- outstanding tokens for that session.
  session_id UUID NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,

  -- (2) Token-at-rest: SHA-256(raw_token). NEVER the raw value. UNIQUE so
  -- a collision (astronomically unlikely but ruled out by the DB anyway)
  -- would reject the second insert instead of clobbering the first.
  token_hash BYTEA UNIQUE NOT NULL,

  -- (3) DB-level TTL: server-side default so a caller cannot forge a
  -- longer-lived token by passing a custom expires_at. 15 minutes is the
  -- reference value; pick the shortest window that fits the UX.
  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  expires_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + INTERVAL '15 minutes',

  -- (4) Single-use marker: set on first successful consume, never unset.
  used_at TIMESTAMPTZ NULL,

  -- (5) Context columns: minimum needed to decide "what does consuming
  -- this token grant?" For AUTH-008 that was existing_user_id + email_hash.
  -- For email-change it might be new_email_hash. For passkey-attach it might
  -- be device_public_key. Name these so a human auditing the table can tell
  -- what each row authorizes.
  <context_col_1> VARCHAR NOT NULL,
  <context_col_2> VARCHAR NULL
);

-- (6) Indexes for the two predictable query paths: scan by session on
-- cleanup + session invalidation, scan by expiry for the reaper job.
CREATE INDEX IF NOT EXISTS idx_<flow_name>_session ON <flow_name>_tokens(session_id);
CREATE INDEX IF NOT EXISTS idx_<flow_name>_expires ON <flow_name>_tokens(expires_at);

Why BYTEA (not TEXT) for token_hash

The reference implementation uses BYTEA because the Node layer hashes with crypto.createHash('sha256').update(raw).digest() (returns a Buffer), and storing the Buffer verbatim avoids the lossy roundtrip through hex/base64. A TEXT column works equally well if you explicitly hex-encode on both sides — be consistent about which you pick.

Adding context columns in a later story

If a later story needs another column (as AUTH-008.2 did with email_hash), ship it as an additive migration (ALTER TABLE ... ADD COLUMN <col> <type> NULL) so earlier tokens in flight remain consumable. Backfilling to NOT NULL should wait one expiry-window beyond the deploy.


Atomic Consume Pattern

The single biggest class of mistakes on this pattern is checking the session binding after the token is burned. If the burn succeeds first, an attacker who triggers a session-mismatch in the application layer has still managed to burn the victim's only valid token — denial-of-service on a legitimate flow.

The consume must be one statement:

sql
UPDATE <flow_name>_tokens
SET used_at = NOW()
WHERE token_hash = $1          -- proves possession of the raw token
  AND session_id = $2          -- binds consumption to the originating session
  AND used_at IS NULL          -- not already consumed (single-use)
  AND expires_at > NOW()       -- not expired
RETURNING *;

Four guards, one statement. If any guard fails, the row is not touched and RETURNING yields zero rows. The caller sees null and returns 410. There is no failure mode in which the token is burned but the session-binding check then fails separately — both resolve atomically.

Reference DAL

typescript
// src/db/<flow>-queries.ts
export async function consume<Flow>Token(
  tokenHash: Buffer,
  sessionId: string,
): Promise<<Flow>Token | null> {
  const result = await pool.query(
    `UPDATE <flow_name>_tokens
     SET used_at = NOW()
     WHERE token_hash = $1
       AND session_id = $2
       AND used_at IS NULL
       AND expires_at > NOW()
     RETURNING *`,
    [tokenHash, sessionId]
  );
  return (result.rows[0] as <Flow>Token) ?? null;
}

A DELETE ... RETURNING variant is equally correct and has the advantage of keeping the table small (the reaper job becomes unnecessary). The reference implementation uses UPDATE ... SET used_at so that post-incident forensics can enumerate historical consumes. Pick based on whether you value that audit depth more than table size.


HTTP Contract

StatusMeaningResponse body
200Token consumed, downstream proof-of-ownership check passed, operation completeWhatever the downstream flow returns (e.g. updated session info)
400Request validation failed (missing/malformed { token, ... }){ success: false, message: 'validation error' }
401No valid session (cookie missing, session expired) OR proof-of-ownership check failed (e.g. wrong password). The same status covers both so the attacker can't distinguish "my session is bad" from "my guess is wrong" without already holding a valid session{ success: false, message: 'Invalid credentials' } or { success: false, message: 'Not authenticated' } depending on which failed first
410Token failure — doesn't exist OR expired OR already used OR session-mismatch. All four collapse into one response so nothing leaks about which specific guard rejected the consume{ success: false, message: 'Challenge token is invalid or has expired. Please restart the linking process.' }
429Rate-limited (share the existing auth rate-limiter; don't invent a new one)Rate-limiter default
500Canister call failed, DB lost, session vanished mid-flow, etc. Log with full internal detail; return a generic message{ success: false, message: 'Internal server error' }

The 410-covers-four-failures rule is what makes the info-leak threat impossible. Under no circumstance should the response body or status distinguish "wrong session" from "expired" — log the specific reason internally; return the uniform 410 to the client.


Audit Logging Contract

Every code path — success and every failure mode — writes exactly one writeAuditLog entry plus a structured securityLogger.auth entry.

typescript
// Success
await writeAuditLog({
  eventType: '<flow>_success',
  userId: <resolved_user_id>,
  ipAddress,
  userAgent,
  detail: JSON.stringify({ /* non-sensitive context */ }),
});

// Token-level failure (all four collapse into one event type; the `reason`
// field distinguishes them INTERNALLY for forensics but is never returned)
await writeAuditLog({
  eventType: '<flow>_failed',
  userId: session.user_id,
  ipAddress,
  userAgent,
  detail: JSON.stringify({ reason: 'invalid_or_expired_token' /* or wrong_password, etc. */ }),
});

What must not appear in the audit payload

  • Raw tokens (the whole point of storing only the hash is to make the plaintext unrecoverable)
  • Session IDs in the detail JSON — log the userId in the top-level field so it's anonymizable with a single function, but never leak session.id into free-form detail text. During AUTH-008.2 review an expected_session field in a failure-path detail was caught and removed for exactly this reason.
  • Unhashed emails, passwords, or other PII — pass only the hash (email_hash, user_id) or a redacted placeholder

Example Implementations

The reference implementation lives in oracle-bridge and covers II ↔ email linking:

ComponentPathWhat it does
Migration (initial)liquibase/changelog/016_ii_email_challenge_tokens.sqlTable schema — session FK, token_hash UNIQUE, DB-default expiry, used_at column
Migration (additive)liquibase/changelog/017_challenge_token_email_hash.sqlAdds email_hash context column via additive ALTER TABLE ... ADD COLUMN ... NULL so earlier in-flight tokens stay valid
DALsrc/db/auth-queries.tscreateIIEmailChallengeToken, consumeIIEmailChallengeTokenCreate with 32-byte CSPRNG + SHA-256 hash; consume via atomic UPDATE ... RETURNING
Issuer routesrc/routes/auth.ts (complete-profile, POST /api/auth/complete-profile)On email-conflict detection, guards session validity BEFORE creating the token (null-session guard — see anti-pattern below), then returns the raw token once in the 409 body
Consumer routesrc/routes/confirm-email-link.ts (POST /api/auth/confirm-email-link)Validates session → consumes token atomically → verifies password → calls canister → swaps session → audits
Testssrc/routes/__tests__/Integration tests cover: expired token, already-used token, wrong session, wrong password, missing email_hash, canister-link failure, happy path

Copy this shape. Rename the migrations, the table, the DAL functions. The shape of each file stays identical.


Anti-Patterns (Do Not)

1. Issuing the token before the session-validity check

typescript
// WRONG — if session is gone, we've burned a random slot in the table with
// a token the client can never use (client may receive it but consumer will
// reject it as session-mismatch, wasting their only attempt).
const token = randomBytes(32).toString('hex');
await createFlowToken({ sessionId: session?.id ?? null, ... }); // NULL session will fail the FK anyway, but
return res.status(409).json({ challengeToken: token });

// RIGHT — resolve + validate session FIRST, then issue token
const session = await getSessionByTokenHash(hashToken(accessToken));
if (!session) {
  return res.status(401).json({ success: false, message: 'Session expired. Please log in again.' });
}
// now safe to mint a token bound to session.id

The AUTH-008.1 review caught this exact bug: the handler was returning challengeToken in a 409 body even on the null-session path, where no row was created. Client ended up with an orphan token.

2. Returning the token (or any token body) on failed-auth paths

Do not return the token in the 401/403 branches. If the caller failed authentication, they have no right to any state-carrying value. Return only the generic error message.

3. Math.random() anywhere near the token

Node's crypto.randomBytes(32) or Web Crypto's crypto.getRandomValues(new Uint8Array(32)) are the only acceptable sources. Math.random() is not cryptographic and has been catastrophically broken multiple times in every runtime that ships it. Never use it here.

typescript
// WRONG
const token = Math.random().toString(36).substring(2);

// RIGHT
import { randomBytes } from 'crypto';
const token = randomBytes(32).toString('hex');

4. Storing the raw token

sql
-- WRONG — DB exfil now grants an attacker every outstanding token
token TEXT NOT NULL,

-- RIGHT
token_hash BYTEA UNIQUE NOT NULL,

The token is returned to the client once, in the creation response. After that it exists only as a hash. The client re-presents the raw value on consume; the server rehashes and compares.

5. Non-atomic consume

typescript
// WRONG — two-step: read then update. A concurrent consume wins the race.
const token = await db.query('SELECT * FROM flow_tokens WHERE token_hash = $1 AND session_id = $2', [hash, sid]);
if (!token || token.used_at || token.expires_at <= new Date()) return null;
await db.query('UPDATE flow_tokens SET used_at = NOW() WHERE id = $1', [token.id]);

// RIGHT — one statement, four guards in the WHERE, RETURNING for the row
// (see Atomic Consume Pattern above)

6. Session binding added after token burn

typescript
// WRONG — token burned first, session checked after. An attacker can DoS
// the legitimate user by triggering a session-mismatch on their token.
const token = await db.query(
  'UPDATE flow_tokens SET used_at = NOW() WHERE token_hash = $1 AND used_at IS NULL RETURNING *',
  [hash]
);
if (token.session_id !== sid) {
  return null; // too late — token is now burned
}

// RIGHT — session_id is part of the WHERE, so consumption AND binding
// succeed-or-fail atomically.

AUTH-008.2 shipped this exact bug in its first draft. Caught in review as CRITICAL #1, fixed by moving session_id into the WHERE.

7. Distinguishing failure modes in the HTTP response

typescript
// WRONG — leaks which guard failed
if (!token) return res.status(404).json({ error: 'token not found' });
if (token.used_at) return res.status(410).json({ error: 'already used' });
if (token.expires_at <= new Date()) return res.status(410).json({ error: 'expired' });
if (token.session_id !== sid) return res.status(403).json({ error: 'wrong session' });

// RIGHT — one collapsed 410, internal `reason` in the audit log only
if (!token) {
  await writeAuditLog({ eventType: '<flow>_failed', detail: JSON.stringify({ reason: 'invalid_or_expired_token' }) });
  return res.status(410).json({ success: false, message: 'Challenge token is invalid or has expired. Please restart the linking process.' });
}

  • ADR: Bridge vs. II Writes — which canister-write pattern pairs with which session model
  • Cross-Suite Authentication Architecture — cookie SSO architecture, CSRF protection, session lifecycle
  • Oracle-Bridge Canister Patterns — controller-signed actor factories used when the post-consume step calls a canister
  • AUTH-008 Epic Retro — hello-world-workspace/bmad-artifacts/implementation-artifacts/epic-auth-008-retro-2026-04-18.md — the full set of review findings that hardened this pattern

Created 2026-04-18 — AI-AUTH8-03

Hello World Co-Op DAO