Authentication And Identity Systems

One-Time Email Login And Magic Links

A magic link logs a user in by emailing them a URL containing a single-use token; clicking it authenticates them, no password required. The same mechanism — a one-time, expiring, unguessable token delivered to a channel the user controls — underlies password reset, email verification, and email-based one-time codes. Because this pattern is an authentication path, and often a full account-takeover path (a password reset can set a new password), getting its token mechanics right is one of the highest-leverage skills in this track.

The Token Is The Whole Security

Every property of the token matters, and each maps to an attack it prevents:

  • Unguessable. Generate it with a cryptographically secure source (random_bytes), not rand(), uniqid(), or a timestamp. A predictable token is a skeleton key — an attacker who can guess the next token logs in as its intended recipient.
  • High-entropy. Long enough (128+ bits) that brute-forcing the token space is hopeless. This is why emailed links can be long random strings but SMS codes must be paired with strict attempt limits — a six-digit code has only a million values and is guessable without rate limiting.
  • Single-use. Consumed on first use and invalidated, so a link that leaks (forwarded email, proxy logs, browser history) cannot be replayed.
  • Time-limited. Short expiry (minutes for high-stakes actions), so a token sitting in an old inbox is not a permanent backdoor.
  • Stored as a hash. Store only a hash of the token server-side, so a leak of your database does not leak usable login tokens — the same logic as password storage.
  • Bound to its purpose and user. A password-reset token must not also work as a login token; scope each token to one action and one account.
PHP example
<?php

declare(strict_types=1);

$token = bin2hex(random_bytes(32));       // 256 bits, unguessable
$stored = hash('sha256', $token);          // store the hash, email the token
$expiresAt = time() + 900;                 // 15 minutes

// On click: hash the presented token, look it up, check expiry, then delete it.
$presented = $token;
$valid = hash_equals($stored, hash('sha256', $presented)) && time() < $expiresAt;

echo $valid ? 'accepted (now invalidate it)' : 'rejected';
echo PHP_EOL;

// Prints:
// accepted (now invalidate it)

Note hash_equals for the comparison — a constant-time check so token verification does not leak via timing, and random_bytes for generation. These two functions are the difference between a secure implementation and a subtly broken one.

Flow Security Beyond The Token

The token mechanics are necessary but not sufficient. The request side must respect the enumeration principle: "if an account exists, we've sent a link" — never confirm or deny the address, or the feature becomes an account-existence oracle. The request endpoint must be rate limited, because it sends email on demand and is otherwise a spam-and-cost amplifier aimed at any address. And for password reset specifically, invalidate existing sessions on a successful reset — the whole point may be to recover from a compromise, so leaving the attacker's session alive defeats it.

One subtle risk: some email security scanners pre-fetch links in messages, which can consume a single-use token before the user clicks. The common mitigation is to require a confirming POST on the landing page (a "Yes, log me in" button) rather than authenticating on the bare GET, so a scanner's GET does not burn the token.

Where This Pattern Applies

Magic-link login, password reset, email verification, and "email me a code" flows are all the same machine with different labels. Password reset is the highest-stakes instance because it grants full account control, so it gets the shortest expiry, guaranteed single use, and session invalidation. Email verification is the lowest-stakes and can use a longer expiry. Recognizing them as one pattern means you implement the token core once, correctly, and vary only expiry and post-action behavior.

What To Check

Before moving on, make sure you can:

  • generate tokens with random_bytes and compare with hash_equals, and say why
  • list the token properties (unguessable, high-entropy, single-use, expiring, hashed, scoped) and the attack each prevents
  • apply the enumeration principle and rate limiting to the request side
  • invalidate sessions on password reset and explain why
  • mitigate link pre-fetching by scanners
  • recognize magic link, reset, verification, and email-code as one pattern

What You Should Be Able To Do

After this lesson, you should be able to implement any email-token flow with correct token mechanics, secure both the request and redemption sides, tune expiry and single-use to the stakes of the action, and reuse one well-built core across login, reset, and verification.

Practice

Practice: Design A Password Reset Flow
Show solution

Token: bin2hex(random_bytes(32)) for 256 bits of unguessable entropy (defeats prediction and brute force). Store only hash('sha256', $token) with the user id and an expiry 15 minutes out (a DB leak yields no usable tokens; a stale inbox link is dead). Email the raw token in the link. Any previous outstanding reset token for that user is invalidated so only the newest works.

Redemption side: the landing page validates the presented token by hashing it and comparing with hash_equals, checking expiry, and confirming it is unused — but authenticates only on a confirming POST ("Set new password"), so email-scanner GET pre-fetches do not consume it. On submit, set the new password via password_hash, then delete/consume the token (single use).

On success: invalidate all existing sessions and refresh tokens for the user, because reset is the recovery-from-compromise path and must lock out any attacker session. Notify the user by email that their password changed, so an unexpected reset is visible to the real owner.

Every property traces to an attack: unguessable/high-entropy vs. prediction, single-use vs. replay, expiry vs. stale-link, hashed vs. DB leak, uniform response vs. enumeration, rate limit vs. email abuse, session invalidation vs. persistent attacker.

Practice: Diagnose A Reset Token Weakness

An app's password reset generates the token as md5($email . time()), stores it in plaintext, does not expire it, and reveals "No account with that email" on the request form. An attacker resets arbitrary accounts.

Task

Enumerate every weakness and the attack it enables.

Show solution
  • Predictable generation: md5($email . time()) is computed from two values the attacker largely knows — the target email and the approximate second the request was made. The attacker requests a reset for the victim, then brute-forces time() across a small window of seconds to reproduce the exact token without ever seeing the email. This alone is full account takeover. Tokens must come from random_bytes, not a hash of guessable inputs.
  • Plaintext storage: a database read yields working reset tokens for every pending request. Store only a hash.
  • No expiry: a token is valid forever, so any leaked link (forwarded mail, logs, history) is a permanent backdoor, and the brute-force window above never closes. Add a short expiry.
  • Enumeration on the request form: "No account with that email" turns the endpoint into an account-existence oracle, handing the attacker a validated target list. Respond uniformly.

Missing controls beyond the token: no evident rate limiting on the request endpoint (email abuse), and the prompt does not mention single-use or session invalidation on success.

The fix is the whole correct pattern: random_bytes token, stored hashed, short expiry, single-use, uniform response, rate limited, sessions invalidated on success. The predictable-token flaw is the most severe because it needs no leaked link at all — the token can be derived.

Practice: Define Email-Token Flow Checks

Define the checks that verify any email-token flow (magic link, reset, verification) is implemented safely.

Show solution
  • A test asserts tokens are generated via a CSPRNG (random_bytes) and are at least 128 bits — a grep-and-test guard against someone reintroducing uniqid/rand/md5(time()).
  • A test confirms the stored value is a hash, not the raw token, and that comparison uses hash_equals.
  • Tests assert single-use (a token works once, then fails) and expiry (a token past its lifetime fails).
  • A test asserts the request endpoint responds identically for existing and non-existing accounts, and is rate limited.
  • For reset specifically, a test asserts all sessions are invalidated on success.
  • A test confirms authentication happens on a confirming POST, not the bare GET, so scanner pre-fetch cannot consume the token.

Human, periodically:

  • Confirm all three flows (magic link, reset, verification) share one token core rather than three divergent hand-rolled ones — divergence is where one of them silently loses a property.
  • Check that expiry and single-use are tuned to each flow's stakes (reset shortest and strictest; verification may be longer).
  • Verify reset-success notifications reach users, so unexpected resets are visible.