Dev.to Security 🔐 Cybersecurity 👁 0 📖 19 min read

Your password reset link is a bearer credential: designing signed, single-use tokens that fail closed

A password-reset link went out on a Tuesday. The user clicked it, changed the password, and forgot about it. Eleven days later the same link still worked, because the service had put the user id and an issued-at timestam

A password-reset link went out on a Tuesday. The user clicked it, changed the password, and forgot about it. Eleven days later the same link still worked, because the service had put the user id and an issued-at timestamp in the URL and signed nothing at all — the "token" was ?uid=8412&t=1731000000, and the only check was that the timestamp was not in the future. Anyone who had seen that URL — in a shared screen, in a screenshot pasted into a support thread, in the browser history of a borrowed laptop — could reset that account as long as they got there first.

The failure is not exotic. It comes from treating a URL as a page address when the URL is actually a bearer credential: whoever holds it is the holder, and the server has no way to ask "but is it you?"

That distinction is the whole design. A link that carries authority needs three properties that a page address never needs: it must be unforgeable (nobody can mint or edit one), it must be bounded in time (a link found in an old log is worthless), and for anything that changes state, it must be spendable once (a second click is not a second effect). Everything else in this article is the consequence of those three, plus the places where a link quietly stops being secret.

What a link token has to survive

Before the code, the list. Each row is a different mechanism, and a design that solves one while leaving another open is not half-safe, it is unsafe.

attack what the attacker does what stops it
forgery edits the payload, keeps the signature, hopes nobody checks a MAC over the exact received bytes
guessing enumerates short codes ≥128 bits of entropy, or a per-family rate limit if the code must be short
replay uses a valid link twice, or two people click at once a redemption ledger with a unique key, written atomically
staleness uses a link from last month's log exp checked against the server clock
disclosure reads the token out of a Referer header, an access log, or a screenshot not putting secrets or PII in the URL, plus a referrer policy
key compromise mints tokens with a stolen key kid in the token, short TTL, two live keys during rotation, per-purpose keys
oracle learns from error messages which check failed one uniform failure to the outside, structured reasons on the inside
ambiguity you cannot tell an attack from a mail delay a metric per failure reason

Anatomy: three segments, and the first one is a promise

v1.<payload>.<mac>

payload is base64url (no padding) of a compact JSON object or a packed field string; mac is base64url of HMAC-SHA256(key, "v1." + payload).

The version prefix is not decoration. It is the one thing that lets you change the payload shape, the hash, or the field names later without a flag day, because a verifier can say "I do not know v2" instead of "this signature is invalid" — and those are different operational events. Put it in the signed region, never outside it.

import base64, hashlib, hmac, json, time

def b64u(raw: bytes) -> str:
    return base64.urlsafe_b64encode(raw).decode().rstrip("=")

def b64u_dec(s: str) -> bytes:
    return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4))

def issue(key: bytes, kid: str, claims: dict) -> str:
    payload = b64u(json.dumps({"kid": kid, **claims},
                              separators=(",", ":"), sort_keys=True).encode())
    signing_input = f"v1.{payload}".encode()
    mac = b64u(hmac.new(key, signing_input, hashlib.sha256).digest())
    return f"v1.{payload}.{mac}"

Two details that look cosmetic and are not:

  • base64url, unpadded. Standard base64 contains + and /, which do not survive a URL, a query parser, or a copy-paste into a chat app intact. Padding = is the character most often mangled by mail clients that wrap long lines. Base64url is case-sensitive, so the token must never be lowercased by a proxy that "normalises" URLs.
  • separators=(",", ":") and sort_keys=True at issue time. Not because verification re-serialises the JSON — it must not — but so that two issuers of the same claims produce the same bytes, which makes logs and tests comparable.

Verify in a fixed order, and fail closed

Verification is a sequence of increasingly expensive checks, and the order is part of the design: cheap rejections first (so a garbage token costs nothing), and the one check that writes to the database last.

  1. Shape bounds. Reject anything longer than, say, 512 characters or shorter than the minimum before parsing. This is not only hygiene: unbounded input to a parser is how you get a denial of service from a query string.
  2. Split into exactly three parts. split(".") on a token with four dots is a malformed token, not a payload with a stray dot.
  3. Known version. v1 or refuse. Unknown versions get their own internal reason — a v2 token hitting a v1 verifier is a deploy-order bug, not an attack.
  4. Key lookup by kid. The token names its key; the verifier holds a small map of kid → key (one active, one retiring). A token with an unknown kid is rejected before any MAC computation.
  5. Recompute the MAC over the exact received bytes of "v1." + payload, as a string, not over a re-parsed and re-serialised object. This is the canonicalisation trap: {"a":1,"b":2} and {"b":2,"a":1} are the same object and different bytes, and a verifier that parses then re-serialises will accept a token whose signature covers different bytes than the ones it displayed. Sign and verify the same string, always.
  6. Compare in constant time (hmac.compare_digest). Never ==.
  7. Claims, against the server clock. exp (absolute epoch seconds), optional nbf, aud, and a scope.
  8. Spend it once, if the token is for a state change. This is the only step that writes.
  9. Then act, and only then.

Fail closed at every step. The failure mode to avoid is the try/except: pass around verification that someone added so the login page would stop 500-ing during an incident; after that commit, an exception anywhere in verification means "valid". Any exception in verification must mean invalid, and the only accepted escape hatch is a feature flag that refuses everything.

Constant time is not paranoia, it is the shape of the check

# wrong: returns as soon as one byte differs
if given_mac == expected_mac: ...

# right: compares every byte, and the comparison time does not depend on where they differ
if hmac.compare_digest(given_mac, expected_mac): ...

A short-circuiting compare leaks the length of the correct prefix. Over a network, that signal is noisy and needs many samples, which is exactly why it is often dismissed — and exactly why it gets exploited against any endpoint that will answer thousands of requests: an HMAC you can nibble byte by byte stops being a secret. The habit matters more than the individual endpoint, because the same comparison pattern ends up guarding API keys and session ids, and in a warmer environment (same datacenter, no TLS terminator, no jitter) the signal is much cleaner than over the public internet.

Language notes, since this is where implementations differ:

  • Python: hmac.compare_digest(a, b) on bytes or ASCII str. Given a non-ASCII str it raises TypeError rather than comparing — so encode to bytes first, which is what you should be comparing anyway.
  • Node: crypto.timingSafeEqual(a, b) throws if the buffers differ in length, so compare lengths first and treat a length mismatch as invalid; do not pad.
  • Go: subtle.ConstantTimeCompare(a, b) returns 0 unless lengths match.
  • Anything else: compare HMAC(comparison_key, given) with HMAC(comparison_key, expected), both fixed 32 bytes, then it does not matter how you compare.

One more channel people forget: comparing base64 text instead of raw bytes is fine only if both sides are canonical base64. If one side is padded and the other is not, a correct token fails. Compare decoded bytes, where the encoding question disappears.

Expiry is a promise you can only keep with a clock you control

exp is absolute epoch seconds, computed once at issue:

claims = {
    "sub": "u_8f21c",
    "scope": "password_reset",
    "exp": int(time.time()) + 900,   # 15 minutes
    "iat": int(time.time()),
    "jti": b64u(os.urandom(16)),
}

Not ttl that gets re-added at check time. A relative field in a token is an instruction to whoever holds it ("start the clock when you read this"), which makes the token's lifetime equal to its propagation delay — the thing you were defending against.

  • Never trust the client's clock. The token says nothing about the local time; the verifier uses its own now(). If several servers verify, they need a shared time source, and the honest way to handle skew is a tolerance in one direction (now - 60 ≤ exp), documented, not a per-host random.
  • TTL is chosen by consequence, not by convenience. A password reset is 15–60 minutes. An email verification is a day. A download link is minutes, because it is usually pre-signed and pre-shared. An invite link is days or weeks, because its whole point is that a human passes it around. Writing the number down per token type, in one table in the repo, prevents the drift where everything inherits whatever the first token used.
  • The TTL is also the revocation strategy. A signed token cannot be un-issued. So the answer to "how do I revoke it?" is "it expires in 15 minutes" plus, for the cases where that is too long, a blocklist of jti or scope — which is a database read, so it should apply only to the token types that need it. Paying a read on every invite click to be able to revoke a token that lives 14 days is a bad trade; a 15-minute reset link needs no blocklist at all.
  • Store iat, and log it. When someone says "the link never worked", the useful answer is "it was issued at 09:14 and expired at 09:29, and you clicked at 10:02", and that answer requires a field you only keep because you put it in.

Single use needs a write, not a read

A signed token removes the read from the hot path — you do not need to look up a session row to know the token is genuine. It does not remove the write, and this is the part people skip: a valid token is valid to everyone who holds it, so two people clicking at the same second both pass every check above.

The primitive is a unique constraint, not a SELECT:

CREATE TABLE redemptions (
  token_hash TEXT PRIMARY KEY,      -- sha256 of the full token, hex
  redeemed_at TEXT NOT NULL,
  ip_hash     TEXT
);
cur = db.execute(
    "INSERT INTO redemptions(token_hash, redeemed_at, ip_hash) "
    "VALUES (?, ?, ?) ON CONFLICT(token_hash) DO NOTHING",
    (hashlib.sha256(token.encode()).hexdigest(), now_iso(), ip_hash))
if cur.rowcount == 0:
    return fail("already_used")

That is atomic in every SQL database worth using: the second writer blocks, then sees a conflict and changes zero rows. The tempting version — SELECT ... WHERE token_hash = ?, and if empty, INSERT — has a time-of-check/time-of-use gap between the two statements, and the two events that arrive in that gap are exactly the two clicks you were trying to distinguish. Under load it also does the wrong thing at the wrong time: the busiest moment is when a link gets posted somewhere.

Three smaller decisions inside this one:

  • Key the ledger on the token hash, not on a jti claim. You then do not need a jti at all, the key is fixed length, and a token cannot dodge the ledger by omitting a field. (Keep jti if you need to correlate a token with the log line that issued it; hash the token for the uniqueness.)
  • Ledger rows can be deleted after the maximum TTL. Once a token cannot pass the exp check, its row is dead weight. A nightly DELETE WHERE redeemed_at < now - max_ttl keeps the table small, and the table being small is what lets it live in the same database as the thing you are protecting.
  • A lost or restored ledger is a real failure. If the table is restored from a backup taken an hour ago, tokens spent in that hour are spendable again. For an invite that is a curiosity; for a payment or a password reset it is a security incident. Say so in the runbook instead of discovering it.

Put a capability in the token, not a subject

The payload should describe what the holder may do, not who they are:

{"kid":"k2","scope":"invite","slot":"RDKXK77G9ZRT","max_uses":1,"exp":1731700000}

A token that names the subject ({"email":"[email protected]"}) is a design smell in a URL, for two reasons:

  1. Base64 is not encryption. Anyone can decode the payload of any token they hold, so a signed token carrying an email address is a personal-data disclosure with a checksum attached — sitting in browser history, in a mail provider's link scanner, in the access log of every proxy in the path, and in whatever support tool someone pasted it into.
  2. A subject and an action are different authorisations. "This is u_8f21c" is a session; "u_8f21c may reset their password once, in the next 15 minutes" is a capability. Mixing them produces tokens that are valid for more than they were issued for, which is how one leaked link becomes an account takeover instead of a wasted click.

If you need the subject, put an opaque id (sub: "u_8f21c") and resolve it server-side. If you need the action, check the scope at the resource — a reset token presented to the invite endpoint must fail there, not because the endpoints are different URLs, but because the scopes differ.

The four places a link stops being secret

This is the part that survives every cryptographic decision above. A perfectly signed, single-use, short-lived token is still handed to whoever reads the URL, and the URL travels further than people think.

1. The Referer header. Any page reached through the token that loads a third-party resource — an analytics script, a font, an embedded video, a CDN — sends the referring URL, token included, to that third party, where it lands in someone else's access log. The fixes are cheap and should all be on:

  • <a rel="noopener noreferrer"> on every outbound link from a token-bearing page;
  • Referrer-Policy: no-referrer (or strict-origin-when-cross-origin, which strips the path) on the redemption page;
  • no third-party scripts at all on the page that receives a token;
  • redirect off the token once it is spent: 302 from /r/<token> to /welcome, so the token does not stay in the address bar, in history, or in the Referer of the next click.

2. Your own logs. The default web-server access log records full request lines, query strings included. If tokens arrive in ?token=, every log line is a credential and every log archive is a credential store. Two rules: put tokens in the path, not the query, so log-scrubbing rules can be precise; and log a fingerprint (sha256(token)[:12]) in the application, so you can answer "was this token used, and when?" without ever storing the token.

3. The browser. History, sync, autocomplete, tab restore, and the "copy link" reflex. There is no fix at the browser level; the mitigation is that the token is short-lived and single-use, and that the page does not ask the user to keep it.

4. Deliberate forwarding. A bearer token cannot tell the intended recipient from whoever holds it — that is the definition, not a bug. For an invite, forwarding is the feature, and the design should accept unlimited holders with a fixed number of slots, counting successful redemptions rather than clicks. For a password reset, forwarding is the attack, and the defence is not a smarter token: it is a short TTL, single use, and a notification to the account owner that a reset was requested.

Rate limiting: for the tokens that are short on purpose

With 128 bits of randomness (16 bytes from os.urandom) or an HMAC-SHA256 MAC, online guessing is not the threat; you will not enumerate that space before the sun burns out. Rate limiting is still needed, for a different reason: the tokens that are short on purpose.

A six-character code that a person can read over the phone holds about 30 bits. At 10 requests per second that is a few years; at 10,000 requests per second from a botnet, it is hours. So:

  • Two buckets, always: per-client (hashed IP, 10/minute) and per-namespace (a code family, or a single code being guessed, 100/minute globally). The second one is what actually stops a distributed attempt, because the attacker's IPs are cheap and your code space is not.
  • Hash the IP before using it as a key. You need a counter, not an identity, and an IP-keyed table is a personal-data store you did not intend to build.
  • 429 with Retry-After, and no distinction between "rate limited because the code exists" and "rate limited because it does not".
  • A failed-attempt counter per code, so a code that is being hammered can be burned outright. Rate limiting buys time; the ability to kill a code is what ends the attempt.
  • Never let rate limiting be the only defence for a short code. If the code must be human-typable, make it longer (8+ characters from a 32-character alphabet) and make it expire in minutes.

Key rotation is a token property, not an ops ritual

Because kid is inside the token, rotation is a two-line change on the verifier:

  1. Generate k3, add it to the signing key set, start issuing with kid: "k3".
  2. Keep k2 in the verify-only set for max_ttl + margin, then remove it.

Without kid, rotation requires either a flag day (all outstanding links break) or a verifier that tries every key in the ring (which is fine at two keys and a liability at six). And two rules that are about blast radius, not rotation:

  • The signing key must not live in the same store as the data. If the HMAC key is a column in the database that holds the redemptions, a database dump lets the attacker mint tokens for anything. The key belongs in the environment, a secret manager, or a KMS — and the point of the MAC is that it is the entire security of the scheme.
  • One key per purpose. An invite key and a reset key, so a leak in the low-value system does not authorise the high-value one, and so the low-value key can be long-lived without making the high-value one long-lived.

Failure is a state, not an exception

Every check above fails for a reason, and the reason is operationally valuable:

internal reason what it usually means
malformed a truncated link from a mail client, or a scanner rewriting the URL
unknown_kid a deploy that issued with a key this host does not have yet
bad_mac tampering, or a proxy that lowercased or re-encoded the token
expired a slow mail queue, or a user who came back the next day
already_used a double click, a mail scanner that prefetches links, or a shared link
rate_limited a bot, or an office behind one NAT address

Externally, all of these should look the same to an anonymous caller: one page, one status, one message ("this link is no longer valid"). The difference between expired and bad_mac is an oracle — it tells an attacker whether a guessed token was structurally real, and it tells a victim's attacker whether the link they hold is the wrong one or merely the old one. Internally, keep the distinction and count it, because already_used spiking after a mail campaign means your scanner is prefetching links, and expired spiking means your queue is late. Those are two different fixes, and you can only tell them apart if you threw away the external difference and kept the internal one.

Tests: mutate one byte at a time

The test suite for a token format is mostly a list of ways to be wrong, and mutation testing writes most of it for you:

  • flip one byte in the payload → must fail;
  • flip one byte in the MAC → must fail;
  • swap the two segments → must fail;
  • drop the last character → must fail;
  • add a fourth segment → must fail;
  • change the version to v2 → must fail with unknown_version;
  • sign with a different key → must fail with bad_mac;
  • exp one second in the past → must fail with expired;
  • redeem twice sequentially → second must fail with already_used;
  • redeem twice concurrently (two threads, one barrier) → exactly one success, and this test fails on a SELECT-then-INSERT implementation, which is the point;
  • a payload with non-ASCII text → must round-trip, because the base64 is over UTF-8 bytes;
  • a 10 KB token → must be rejected by the shape bound before parsing.

And a property worth stating as an invariant rather than a test case: any single-character change to a valid token must fail verification. If you can find a mutation that still verifies, you have found either a canonicalisation bug or an encoding bug, and both are worse than they look.

What this design does not fix

  • Phishing. A real, correctly signed link sent by an attacker works exactly as designed. The token proves the issuer meant it, not that the holder is the person you think.
  • A compromised endpoint. Once the token is in the browser, it is the user's authority. Malware on the device does not need to forge anything.
  • Forwarding, by design. A bearer token cannot distinguish the intended recipient from a holder. That is why invites count slots and resets count minutes.
  • A leaked signing key. Rotation bounds the window; it does not undo what was minted inside it.
  • A short code typed by a human. Rate limiting buys time. It does not make 30 bits into 128.
  • Anything about the effect after redemption. Single use guarantees one redemption, not one correct outcome; if the redeemed action is itself retryable, the idempotency conversation is a separate one.

Checklist

  • Tokens are version.payload.mac, base64url without padding, and the version is inside the signed region.
  • The MAC is HMAC-SHA256 over the exact issued string, verified over the exact received string — never over a re-serialised object.
  • Comparison is length-safe and constant-time (compare_digest, timingSafeEqual, subtle.ConstantTimeCompare).
  • Every failure path fails closed; no bare except: pass around verification.
  • kid is in the token; the verifier holds one active and one retiring key; the old key lives for max_ttl after rotation.
  • The signing key is not stored beside the data it protects, and each purpose has its own key.
  • exp is absolute, set at issue, checked against the server clock, with a documented skew tolerance.
  • TTL is chosen per token type and written down where the next developer will find it.
  • Single use is a unique-constraint INSERT, not a SELECT followed by an INSERT.
  • The ledger is keyed on a hash of the token and pruned after the maximum TTL.
  • The payload carries a scope, not a person; no PII and no secret in any URL.
  • Token-bearing pages send Referrer-Policy: no-referrer, load no third-party scripts, and redirect to a clean URL once the token is spent.
  • Access logs never contain full tokens; the application logs a hash prefix instead.
  • All external failures are identical; all internal failure reasons are counted.
  • Short codes have per-client and per-namespace limits, a Retry-After, and the ability to be burned.
  • The test suite mutates every character position of a valid token and asserts failure.

None of this is novel cryptography. It is the ordinary shape of a link that carries authority: sign what you send, compare without leaking, expire against your own clock, spend once at the database, and keep the secret out of the places a URL goes. The books that describe sessions describe the same three properties; a link just makes the credential visible, copyable, and forwardable — which is precisely why it needs the checks that a cookie gets from the browser for free.

Disclosure: I build small automation tools and write about the parts that break. I also maintain an English guide for an AI assistant app whose invite system I use as a concrete example of these ideas: Pickle invite code — what the app does and how to enter it and the invite link pickle.com/i/RDKXK77G9ZRT. If someone joins through that link I receive in-app usage credit; it costs the person joining nothing. Nothing in this article depends on that product, and every pattern above is generic.

📰 Read the original article on Dev.to Security

Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.