DEV Community

kevindev
kevindev

Posted on

Verification Tokens Need a Capability Boundary

Email verification is often implemented as if it were a string-generation problem: create a random value, put it in a URL, and compare it later. That works for a demo, but production authentication needs a stronger model.

A verification token is a capability. Whoever presents it gets permission to perform one narrowly defined action. The backend should therefore control its scope, lifetime, audience, and consumption state. Treating the token as a capability makes replay behavior much easier to reason about, especially when a user clicks the link twice or an email client prefetches it.

This post describes a small REST API and PostgreSQL design that I use as a mental model for these flows. The important part is not the exact framework. It is the boundary between an unconsumed capability and the account change it authorizes.

A token is a capability, not just a string

Start with the action, not the token format. For example, an email verification token might authorize only this operation:

verify email address for account 8472 before expiry
Enter fullscreen mode Exit fullscreen mode

It should not also grant password-reset access, reveal account data, or remain useful after the email has been verified. A token can be opaque to the client and still have a clear contract on the server.

The API contract can stay small:

POST /v1/email-verifications/consume
Content-Type: application/json

{"token":"one-time-value"}
Enter fullscreen mode Exit fullscreen mode

The response should distinguish useful states without exposing sensitive details. An already-consumed token can return a stable business error such as verification_already_completed; an unknown or expired token can return verification_unavailable. This avoids making the token endpoint a user-enumeration oracle.

Search-shaped phrases such as tp mail so or fake e mail com should never affect this contract. They may appear in test data or support notes, but authentication decisions belong to explicit account and token state.

The database contract

The database row needs enough information to enforce the capability boundary. A minimal table could look like this:

CREATE TABLE email_verification_tokens (
    id              BIGSERIAL PRIMARY KEY,
    account_id      BIGINT NOT NULL REFERENCES accounts(id),
    token_digest    BYTEA NOT NULL UNIQUE,
    expires_at      TIMESTAMPTZ NOT NULL,
    consumed_at     TIMESTAMPTZ,
    created_at      TIMESTAMPTZ NOT NULL DEFAULT now(),
    request_id      TEXT NOT NULL
);
Enter fullscreen mode Exit fullscreen mode

Store a digest rather than the raw token. Generate the raw value for the email link, hash it before persistence, and compare a digest during consumption. If an operational snapshot exposes the table, the snapshot should not be enough to use the link directly.

The request_id is useful for tracing and idempotency diagnostics. It is not a replacement for consumed_at: a request can be retried with a different transport identifier, while the capability must still be single-use.

Consume the token in one transaction

The most important rule is that validation and account mutation share one transaction. A read followed by a later update creates a race where two requests can both observe an unused token.

One implementation shape is:

BEGIN;

SELECT id, account_id
FROM email_verification_tokens
WHERE token_digest = $1
  AND expires_at > now()
  AND consumed_at IS NULL
FOR UPDATE;

UPDATE accounts
SET email_verified_at = COALESCE(email_verified_at, now())
WHERE id = $2;

UPDATE email_verification_tokens
SET consumed_at = now()
WHERE id = $3
  AND consumed_at IS NULL;

COMMIT;
Enter fullscreen mode Exit fullscreen mode

The application should check affected-row counts and roll back when the expected state is not present. COALESCE makes the account mutation tolerant of a previous successful verification, but it does not make the token reusable. The token row is still consumed once.

In practise, this transaction is usually shorter than teams expect. Do not call an email provider or another remote service while holding the row lock. Keep the critical section focused on local state, so a slow dependency does not turn ordinary retries into lock contention.

Keep email delivery outside the transaction

Issuing a token and delivering its email are related events, but they are different failure domains. If the provider call happens inside the account transaction, a provider timeout can leave the database transaction open and the user unsure whether another email is safe to request.

A better sequence is:

  1. Create the token row and an outbox event in one transaction.
  2. Commit the account-side state.
  3. Let a worker deliver the message and record the provider result.
  4. Expire or supersede older tokens according to the product policy.

The outbox event should contain an event id and a reference to the token row, not the raw token when that can be avoided. A worker retry then has a clear idempotency key. There is less surprises when delivery retries are observable separately from token consumption.

If the product supports a temporary email account generator in a controlled test or staging workflow, keep that choice in the test fixture configuration. It should not change the production token rules or bypass ownership checks.

Observability and test fixtures

A successful verification should leave a compact audit trail: account id, token id, request id, outcome, and timestamps. Avoid logging the raw token, full verification URL, or the email address when it is not needed. The logs is easier to protect when each field has a clear purpose.

For failures, record a reason category such as expired, already_consumed, not_found, or account_missing. These categories help operations distinguish a real authentication issue from a stale browser tab.

This is also where better alert context for rollouts pays off: a deployment alert should be able to point to verification outcomes and latency, not just a generic increase in HTTP 500 responses. For browser tests, a timeline-based email debugging approach makes it easier to match the token request, delivery event, and consume request.

The test fixture should assert the boundary directly:

  • the first consume request verifies the account;
  • the second consume request does not perform a second state transition;
  • an expired token cannot verify an account;
  • a token for account A cannot affect account B;
  • concurrent consume requests produce one successful transition;
  • raw token values are absent from application logs.

These cases catch more than a happy-path test. They also document the behavior that maintainers should preserve when the authentication code changes.

A practical checklist

Before shipping an email verification flow, check the following:

  • Is the token scoped to one action and one account?
  • Is only a digest stored in PostgreSQL?
  • Are expiry, consumption, and account mutation checked atomically?
  • Are delivery retries handled through an outbox or equivalent durable boundary?
  • Are raw tokens excluded from logs, traces, and analytics?
  • Can support staff explain expired and already-consumed outcomes?
  • Do concurrent requests have a tested, deterministic result?

A small capability boundary is often more valuable than a complex token format. The design keeps the security decision local, makes retries boring, and gives the API a state model that engineers can test.

Q&A

Should the endpoint redirect after a successful consume?

It can, but I prefer a backend endpoint that returns a clear result and lets the client decide where to navigate. If a redirect is required, do not place the raw token in a long-lived analytics trail or a third-party destination.

What if a user requests several verification emails?

Choose an explicit policy: allow several valid tokens with independent rows, or supersede older rows for the same account and action. Either choice is workable when the consume transaction enforces it. The policy should be visible in tests, not hidden in a helper.

Is hashing the token enough?

No. Hashing reduces exposure if the token table leaks, but it does not solve expiry, scope, replay, logging, rate limiting, or account ownership. Those are separate parts of the capability contract.

Top comments (0)