How to Add Passwordless Login with Magic Links in PHP
Generate a random single-use token and a short numeric code, store only their hashes with a 10 to 15 minute expiry, and email both. Sign the user in when either matches. Rate-limit requests and guesses per IP and per email, claim the token atomically, and regenerate the session ID at sign-in.
How email sign-in works
Passwordless email login replaces "something you know" with "something you can receive". The user types their address, you email a secret, and presenting that secret proves control of the inbox. There is no password to store, reset or leak, and no password-reuse problem.
Most implementations send two things in the same email:
- A magic link containing a long random token, for one-click sign-in on the same device.
- A short code, usually six digits, for when the email is read on a phone but the user is signing in on a laptop.
The security of the whole scheme rests on four properties: the secrets are unguessable, short-lived, single-use and never stored in plain text. Everything below serves those four.
Generating and storing the secrets
Use PHP's cryptographically secure generators, hash before storing, and expire older unused secrets for the same address whenever you issue a new one:
$token = bin2hex(random_bytes(24)); // 48 hex chars for the link
$code = (string) random_int(100000, 999999); // 6-digit code
$expiresAt = gmdate('Y-m-d H:i:s', time() + 15 * 60);
$pdo->prepare('UPDATE login_tokens SET used_at = :now
WHERE email = :email AND used_at IS NULL')
->execute([':now' => gmdate('Y-m-d H:i:s'), ':email' => $email]);
$pdo->prepare('INSERT INTO login_tokens (email, token_hash, code_hash, expires_at)
VALUES (:email, :th, :ch, :exp)')
->execute([
':email' => $email,
':th' => hash('sha256', $token), // lookup by hash
':ch' => password_hash($code, PASSWORD_DEFAULT), // slow hash for the short code
':exp' => $expiresAt,
]);
The token has enough entropy that a fast SHA-256 hash is fine and lets you look it up with an index. The six-digit code does not: there are only a million possibilities, so use a slow hash such as password_hash() to make offline guessing of a leaked table expensive. Build the link from a configured base URL (APP_URL), not from the request's Host header, or an attacker can make your server email links to their domain.
Verifying without race conditions
"Single use" has to survive two requests arriving at once, for example a double-click. Selecting the row and then marking it used in a second statement leaves a gap. Make the claim one conditional update and only sign in the request that wins:
$claim = $pdo->prepare('UPDATE login_tokens SET used_at = :now
WHERE id = :id AND used_at IS NULL AND expires_at >= :now');
$claim->execute([':now' => gmdate('Y-m-d H:i:s'), ':id' => $row['id']]);
if ($claim->rowCount() !== 1) {
// someone else already used it, or it expired
return null;
}
For the code path, fetch the user's recent unused, unexpired rows, check each with password_verify(), then claim the matching row the same way. Blocked or deleted accounts should fail here too, not just at request time.
Rate limits and account enumeration
A six-digit code is only safe because guessing is throttled. With one live code and a cap of 20 attempts per email in its 15-minute life, an attacker's chance of guessing it is 20 in 1,000,000. Without a cap, a script can try the whole space. Limit:
- Code requests per IP and per email, plus a short resend cooldown so nobody can flood an inbox.
- Verification attempts per IP and per email, so an attacker cannot spread guesses across addresses or addresses across IPs.
Keep error messages generic. "We've sent a code if that address can sign in" avoids telling an attacker which emails have accounts. If you sit behind a proxy or CDN, only trust X-Forwarded-For when the proxy is yours, otherwise clients can fake their IP and dodge per-IP limits. The API rate-limiting guide shows a database-backed limiter that needs no Redis.
Magic links and email security scanners
Some corporate mail systems and security products open links in incoming email to check them. If your magic link signs the user in on a plain GET, the scanner can consume the single-use token first, and the real user sees "link expired".
Options, from simplest to most robust:
- Make the link open a page with a "Sign in" button that submits a
POST. Scanners rarely submit forms. - Keep the code in the same email so users have a second route, but remember that if both secrets share a row, consuming the link also consumes the code.
- Bind the link to the browser that requested it, using a short-lived cookie, so a scanner elsewhere cannot complete sign-in.
Test with the mail providers your customers actually use before launch.
Sessions after sign-in
Once the secret checks out, call session_regenerate_id(true) to prevent session fixation, and set the session cookie with HttpOnly, Secure and SameSite=Lax. Turn on session.use_strict_mode so PHP rejects session IDs it did not create.
For "remember me", issue a separate random token, store only its hash, and rotate it every time it is used so a stolen cookie works once at most. Add an absolute lifetime so a remembered login eventually requires a fresh email. Because recovery depends on the inbox, consider offering a second sign-in method such as Sign in with Apple or Google.
Test the flow before launch
Passwordless login has few moving parts, but each one fails in a way users notice immediately. Run through these with a real mailbox, not only your local mail log:
- Request a code, wait past the expiry, and confirm both the code and the link are refused with a clear message.
- Request two codes in a row. Only the newest should work.
- Use a link, then press back and use it again. The second attempt must fail.
- Enter a wrong code repeatedly and confirm you are throttled, then that the limit resets after the window.
- Read the email on a phone and type the code on a laptop.
- Block an account and confirm neither the code nor the link signs it in.
- Check the email arrives in the inbox, not spam, at Gmail and Outlook. Delivery setup is covered in sending email with Amazon SES.
Finally, look at your logs and database: no plain-text code or token should appear anywhere, including error messages.
Sources: OWASP Authentication Cheat Sheet · OWASP Session Management Cheat Sheet · PHP: random_int
How Simple Stack handles it
Simple Stack signs users in with email only: no passwords anywhere. The flow is in issue_login_token(), consume_login_by_code() and consume_login_by_token() in app/bootstrap.php, with settings described in SECURITY.md and EMAIL.md.
- Each sign-in email carries a 6-digit code and a magic link; either signs the user in once
- Tokens are stored as SHA-256 hashes and codes with password_hash(); both expire in 15 minutes by default
- Requests and verifications are rate-limited per IP and per email, with a 60-second resend cooldown
- The single-use claim is one guarded UPDATE, so a code cannot be redeemed twice in parallel
- Remember-me tokens are hashed, rotate on every use and stop after 90 days by default
- Without SES in development, the code and link are written to storage/logs/mail.log
Questions people ask
Skip the plumbing. Build the product.
Sign-in, Stripe, emails, an admin and a CMS — already built and tested in plain PHP. £49, once, for unlimited projects.