VibeWeek
Home/Grow/Password Reset & Magic Link Auth: The Flows That Get Most Apps Hacked

Password Reset & Magic Link Auth: The Flows That Get Most Apps Hacked

⬅️ Day 6: Grow Overview

If you're shipping a SaaS in 2026, your password reset flow is the most-attacked surface in your app. The 2024-2025 wave of account-takeover attacks targeted reset flows at scale — leaked email + token-prediction + race conditions + missing rate limits. Most indie apps ship reset flows that look fine in the happy path and get rolled in production. Magic-link login is the modern alternative for many apps, with its own pitfalls (open-redirect, link-leak in shared inboxes, replay attacks) that founders consistently underestimate.

A working flow answers: how are reset tokens generated (cryptographically random, never predictable), how long do they live (15 min for reset; 5-15 min for magic links), how do you prevent brute force (rate limit + lockout), how do you prevent enumeration (don't reveal whether email exists), how do you handle session invalidation (kill all sessions on password change), and how do you handle abuse (someone pummels reset endpoint to spam users).

This guide is the implementation playbook for password reset, magic links, and the secure variants. Companion to Two-Factor Auth, SSO & Enterprise Auth, Social Login & OAuth, and Email Template Implementation.

Why These Flows Matter

Get the threat model clear first.

Help me understand the threat model.

The attacks:

**1. Token prediction**
Sequential or pseudo-random tokens get guessed.
Mitigation: cryptographic randomness (32+ bytes), never UUID v1 / sequential.

**2. Token enumeration in URL**
Reset link in URL → email forwarded → leaked in browser history → leaked in server logs.
Mitigation: short TTL, single-use, log-redaction.

**3. Account enumeration**
"This email is not registered" leaks user list to attackers.
Mitigation: same response for "email exists" and "email doesn't" — always 200 "if account exists, email sent."

**4. Brute force / spray**
Attacker tries N tokens for known emails.
Mitigation: rate limit on reset endpoint per IP + per email; lockout after threshold.

**5. Reset-spam DoS**
Attacker hammers reset for victim email; victim mailbox floods.
Mitigation: rate limit per email (1 reset per 5 minutes); deduplicate within window.

**6. Session-fixation post-reset**
Old sessions stay valid after password reset; attacker keeps hijacked session.
Mitigation: invalidate ALL sessions on password change.

**7. Open-redirect post-reset**
Reset link redirects to attacker URL via "next" param.
Mitigation: allowlist for redirect destinations.

**8. Email-on-other-device login**
Magic link clicked on different device than initiated; possibly attacker.
Mitigation: tie token to initiating-device fingerprint OR explicit confirmation.

**9. Race conditions**
Token used twice; both succeed.
Mitigation: atomic single-use; token marked used at validation.

**10. Cross-tenant token leak**
Multi-tenant app: token issued for tenant A used to access tenant B.
Mitigation: tokens scoped to user+tenant pair.

For my app:
- Auth flows in scope
- Multi-tenant?
- Compliance requirements

Output:
1. Threat priorities
2. Defense priorities
3. Test cases needed

The biggest unforced error: shipping the happy path and never testing the abuse path. Reset flow looks fine for the user who clicks "forgot password" once. Looks broken when an attacker pummels it 10,000 times. Pen-test or fuzz-test the reset endpoint before shipping.

Token Generation: Crypto-Random, Always

Help me get token generation right.

The rules:

**1. Use a CSPRNG**

```javascript
// Node.js
import { randomBytes } from 'node:crypto';
const token = randomBytes(32).toString('base64url');
// 256 bits of entropy → unguessable

// Python
import secrets
token = secrets.token_urlsafe(32)

// Go
import "crypto/rand"
b := make([]byte, 32)
rand.Read(b)
token := base64.RawURLEncoding.EncodeToString(b)

NEVER use Math.random / non-crypto random.

2. Length: 32+ bytes (256 bits)

Brute force at 10^15 attempts/second still takes longer than universe age. Don't penny-pinch on token length.

3. URL-safe encoding

base64url (no padding; no +/=) — works in URLs without escaping. Hex is fine; longer for same entropy.

4. Never reveal user ID in token

Bad: token = userId + ":" + random Good: token = random; lookup user by token

5. Hash before storage

Store SHA-256(token) in DB; compare hash on validation. Why: if DB leaks, raw tokens aren't stored.

CREATE TABLE password_reset_tokens (
  token_hash CHAR(64) PRIMARY KEY,  -- SHA-256 hex
  user_id UUID NOT NULL REFERENCES users,
  expires_at TIMESTAMPTZ NOT NULL,
  used_at TIMESTAMPTZ,  -- NULL = unused
  ip_created INET,
  user_agent_created TEXT,
  created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX ON password_reset_tokens (user_id, expires_at);

6. Email contains raw token; DB has hash

User clicks link with raw token; server hashes; compares to stored. Database breach → attacker has hashes; can't reverse.

For my stack:

  • [Language / framework]
  • [Where tokens stored]

Output:

  1. Generation function
  2. Storage schema
  3. Validation function

The classic failure: **generating tokens with Math.random() and storing the raw token in DB**. Both are mistakes — the first is guessable, the second leaks if DB is compromised. Use CSPRNG; hash before storage.

## The Reset Flow End-to-End

Help me wire up the reset flow.

The endpoints:

POST /auth/reset-request

Input: { email }

Logic:
1. Validate email format (RFC 5322)
2. Rate limit: max 1 per email per 5 min; max 5 per IP per hour
3. Look up user by email
4. If user exists:
   a. Generate token (32 bytes random)
   b. Hash token (SHA-256)
   c. Insert reset row with hash, user_id, expires_at = now + 15 min
   d. Email user with link: https://app.com/reset?token=RAW_TOKEN
5. ALWAYS return 200 "If an account with that email exists, you'll receive a reset link"
   (Same response whether user existed or not — prevents enumeration)

POST /auth/reset-confirm

Input: { token, new_password }

Logic:
1. Validate password complexity (min 8 chars; complexity rules per policy)
2. Hash token (SHA-256)
3. Look up reset row by token_hash
4. If not found → 400 "Invalid or expired token"
5. If used_at IS NOT NULL → 400 "Token already used"
6. If expires_at < now → 400 "Token expired"
7. Atomically:
   a. Update user.password_hash = bcrypt/argon2(new_password)
   b. Mark reset row used_at = now
   c. Invalidate all user sessions (delete from sessions WHERE user_id = X)
   d. Insert audit log: password_reset
8. Send confirmation email: "Your password was reset"
9. Return 200; redirect to login

Rate limiting:

  • 1 reset request per email per 5 minutes (prevents spam)
  • 5 reset requests per IP per hour (prevents email enum at scale)
  • 5 reset CONFIRMS per IP per hour (prevents brute-forcing tokens)

Session invalidation:

DELETE FROM sessions WHERE user_id = ?;

This logs out all devices. User must re-authenticate. Critical for lock-out attacker.

Audit log:

INSERT INTO audit_log (user_id, event, ip, user_agent, created_at)
VALUES (?, 'password_reset', ?, ?, NOW());

For compliance / forensics.

For my app:

  • Stack
  • Rate-limit infrastructure (Redis / DB / nginx?)

Output:

  1. Endpoint implementations
  2. Rate-limit setup
  3. Session invalidation
  4. Audit log

The non-obvious win: **always return 200 "if account exists, email sent"**. Even when the email is invalid. Even when the user doesn't exist. Account enumeration is one of the most common reset-flow vulnerabilities and the easiest to fix.

## Email Template Discipline

Help me write the reset email correctly.

The content:

Subject: "Reset your password" (clear; don't be cute)

Body:

Hi [name],

Someone requested a password reset for your account.

If this was you, reset your password here:
[Reset Password] (button → https://app.com/reset?token=XYZ)

This link expires in 15 minutes.

If this wasn't you, ignore this email — your account is safe.

Need help? Reply to this email.

Critical content rules:

  1. State it expires soon and how soon
  2. Tell user to ignore if they didn't request
  3. Don't include the password (obviously)
  4. Don't include the user's username if not necessary (avoid leaking)
  5. Use a button (HTML) AND plain-text URL (for accessibility / plain-text clients)
  6. Include support contact

From / sender:

  • From: "Acme auth@acme.com" (NOT noreply@; reset is high-stakes)
  • Reply-To: support@acme.com (if user has questions)
  • DKIM, SPF, DMARC aligned
  • BIMI logo for trust

Confirmation email (sent AFTER reset succeeds):

Subject: "Your Acme password was reset"

Body:
Your password was just reset.

If this was you, you can ignore this email.

If this wasn't you, your account may be compromised. Click here to lock your account: [Lock Account]

This catches "attacker reset my password" scenarios. User notices the confirmation email (sent to their email which still works).

Anti-phishing:

  • Email looks like your brand (template, logo, domain consistent)
  • URL is your domain (not third-party tracker that obscures URL)
  • Footer: "We will never ask you for your password by email"

For my email service:

  • [Resend / SES / Postmark / etc.]
  • Template system

Output:

  1. Email template (HTML + plain text)
  2. Subject lines
  3. From-address discipline
  4. Confirmation email

The mistake to avoid: **using a tracking URL wrapper that obscures the destination**. Reset emails benefit from clear URLs (the user can verify visually). If you wrap https://app.com/reset?token=XYZ in https://t.acme.com/track/abc, attackers can phish more easily.

## Magic Link Login (Passwordless)

Help me build magic link login.

The flow:

POST /auth/magic-link/request

Input: { email }

Logic:
1. Rate limit (per email, per IP)
2. Validate email
3. Generate token (32 bytes random)
4. Hash and store: user_id (lookup or create), token_hash, expires_at = now + 10 min
5. Email link: https://app.com/auth/magic?token=XYZ
6. Return 200 "Check your email"

GET /auth/magic?token=XYZ

Logic:
1. Hash token
2. Look up magic_link_tokens
3. If not found / expired / used → render "Invalid or expired link" page
4. Mark token used
5. Create session for user_id
6. Set session cookie
7. Redirect to /dashboard

Validate before redirect:
- Token expired?
- Token already used?
- Tied to expected device? (optional fingerprint check)

Schema:

CREATE TABLE magic_link_tokens (
  token_hash CHAR(64) PRIMARY KEY,
  user_id UUID NOT NULL REFERENCES users,
  expires_at TIMESTAMPTZ NOT NULL,
  used_at TIMESTAMPTZ,
  ip_created INET,
  user_agent_created TEXT,
  ip_used INET,
  user_agent_used TEXT,
  created_at TIMESTAMPTZ DEFAULT NOW()
);

Magic-link-specific risks:

1. Open redirect: "next" param: ?next=https://attacker.com Mitigation: allowlist domains; default to /dashboard if next is external

2. Link in shared inbox: Family / coworker reads email; clicks link; logs in as user. Mitigation: short TTL (5-10 min); device-fingerprint check (optional)

3. Link in email forwarding: Email forwarded to assistant; assistant clicks link. Mitigation: short TTL; warning text "do not forward"

4. Replay: Token reused. Mitigation: single-use; mark used atomically

5. Pre-signin URL not preserved: User wants to deep-link to /reports/123; logs in via magic link; lands on /dashboard. Mitigation: store intended URL on request; redirect after login

Magic link vs password:

Magic link pros:

  • No password to forget / reuse / leak
  • Lower phishing surface (no password to steal)
  • Modern UX

Magic link cons:

  • Email is now the single factor; if email compromised, account compromised
  • Latency: user has to open email tab
  • Email-deliverability dependency (spam folder = locked out)
  • Some users don't get email-on-phone reliably

The 2026 hybrid:

  • Password + 2FA OR magic link OR passkey
  • Let user pick

For my app:

  • Auth model
  • User base preference

Output:

  1. Magic link endpoints
  2. Schema
  3. Anti-abuse measures
  4. Hybrid auth UX

The honest framing in 2026: **magic link is fine for low-risk apps; risky for high-stakes**. Banking, admin tools, enterprise SaaS — keep password + 2FA or move to passkeys. Notes apps, B2C SaaS, content tools — magic link is great UX.

## Passkeys: The 2026 Default for New Apps

Help me think about passkeys.

What passkeys are:

  • WebAuthn-based credentials
  • Stored in Apple Keychain / Google Password Manager / iCloud / 1Password / Bitwarden
  • Private key on device; public key on server
  • Phishing-resistant (tied to domain)
  • No shared secret to leak

In 2026:

  • Browser support: 95%+ on modern browsers
  • Apple / Google / Microsoft: supported with cross-device sync
  • Most major SaaS now offers passkey
  • Best practice: passkey-first, password fallback

The flow:

Registration (new account):

  1. User signs up with email
  2. Verify email (one-time link)
  3. Prompt to create passkey
  4. Browser prompts for biometric (Face ID, fingerprint, PIN)
  5. Public key stored on server; private key on device

Authentication:

  1. User enters email
  2. Server returns WebAuthn challenge
  3. Browser prompts for biometric
  4. Device signs challenge with private key
  5. Server verifies signature

Cross-device:

  • iCloud Keychain sync: passkey created on iPhone usable on Mac
  • Google Password Manager: same across Chrome devices
  • 1Password / Bitwarden: managed in password manager

Library options:

  • @simplewebauthn/server (Node.js)
  • @simplewebauthn/browser (frontend)
  • py_webauthn (Python)
  • go-webauthn (Go)

Auth UI in 2026:

[Email input]
[Continue button]
   → If passkey registered: prompt biometric
   → If no passkey: send magic link OR show password field

Most apps now offer:

  • Sign in with passkey (preferred)
  • Sign in with password (fallback)
  • Sign in with magic link (alternative)
  • Sign in with [Google/Microsoft/Apple] (social)

For my app:

  • Passkey support priority?
  • Existing auth provider (Clerk / Auth0 / Supabase) handles this?

Output:

  1. Passkey integration plan
  2. UX flow
  3. Library / vendor pick

The 2026 default for new apps: **passkey-first**. Email + passkey + password-fallback covers 99% of users. Magic link is a nice fallback for password-resets-equivalent. Most auth providers (Clerk, Auth0, WorkOS, Supabase, Better Auth) ship passkey support — use it.

## Anti-Abuse: Rate Limits, Lockouts, CAPTCHA

Help me set up abuse protection.

The layered approach:

Layer 1: IP rate limit (cheap)

- 5 reset requests per IP per hour
- 10 magic-link requests per IP per hour
- 5 reset confirms per IP per hour
- 100 login attempts per IP per hour

Implementation:

  • Redis counter with TTL
  • Cloudflare / nginx rate limit
  • Vercel / Cloudflare BotID for layer-7 detection

Layer 2: Email rate limit (medium)

- 1 reset per email per 5 min
- 1 magic link per email per 5 min
- Dedupe within window: same email → re-send same token (don't generate new)

Why: prevents reset-spam DoS attacks against specific user.

Layer 3: Account lockout (post-attempt)

- 5 failed login attempts → lock for 15 min
- 10 failed reset confirms → invalidate all reset tokens for user
- Notify user: "We locked your account due to suspicious activity"

Layer 4: CAPTCHA / Bot detection

When automation detected:

  • hCaptcha / Cloudflare Turnstile / Vercel BotID
  • Trigger: > 3 failures from IP in short window
  • Don't show CAPTCHA to legit users (UX cost)

Layer 5: Behavioral signals

  • Login from new country → email user
  • Multiple failures → flag for review
  • Reset request from logged-in session for different account → suspect

For my stack:

  • Rate-limit infrastructure
  • CAPTCHA preference
  • Bot detection vendor

Output:

  1. Layer-by-layer setup
  2. Thresholds
  3. Monitoring / alerting

The cheapest win: **per-email dedup within a 5-min window**. If user clicks "forgot password" three times in a minute, they get the same token (re-emailed) — not three different tokens. Prevents the user-flooding DoS at zero UX cost.

## Testing the Flows

Help me test reset / magic link / passkey flows.

The test categories:

1. Happy path

  • Reset flow works for valid email
  • Magic link logs user in
  • Passkey registration + login succeeds

2. Token validity

  • Expired token rejected (set expires_at to past, attempt)
  • Used token rejected (use once, attempt again)
  • Random invalid token rejected
  • Empty / missing token rejected

3. Account enumeration

  • POST /reset-request with valid email → 200
  • POST /reset-request with invalid email → 200 (NOT 404)
  • Response time same for both (no timing-based enum)

4. Rate limiting

  • 6th reset request from same IP in hour → 429
  • 2nd reset for same email within 5 min → 429 (or returns same token)
  • 6th login fail in hour → 429 + lockout

5. Session invalidation

  • User has 3 active sessions; resets password
  • All 3 sessions become invalid
  • New login required

6. Open redirect

  • ?next=https://attacker.com → redirected to dashboard, not attacker
  • ?next=/internal-page → redirected to internal page
  • ?next=//attacker.com → blocked

7. Email enumeration via timing

  • Time response for valid vs invalid email
  • Should be within 50ms variance

8. Cross-tenant

  • Multi-tenant app: token for tenant A used in tenant B context → rejected

9. Audit

  • Reset event logged with IP, UA, timestamp
  • Confirmation email sent

10. Email content

  • Email contains valid link
  • Link includes correct token
  • Expiration time stated
  • Branding correct

For my app: [test framework]

Output:

  1. Test list
  2. Tools (Playwright? Vitest? curl scripts?)
  3. CI integration

The single most-skipped test: **the enumeration test**. Most teams ship this vulnerability and ship it for years. Add an automated test: "POST /reset-request with random@example.com returns same response as POST /reset-request with real-user@example.com." If responses differ in body, status, or timing, you have a bug.

## Migration: Cleaning Up Legacy Auth

Help me migrate legacy auth.

Common legacy issues:

1. Plain or MD5/SHA-1 password storage Migration: hash existing passwords on next login using bcrypt/argon2; force password reset for stale accounts.

2. Sequential reset tokens (e.g. UUID v1) Migration: invalidate all existing tokens; new tokens use crypto-random.

3. No token expiration Migration: add expires_at column; backfill = now (forces all to re-reset); future tokens have TTL.

4. Tokens stored raw in DB Migration: hash existing tokens in-place (SHA-256); validation switches to hash compare; raw tokens still valid until expiration.

5. No rate limiting Migration: add Redis-backed rate limiter; ship before pen-test.

6. Email enumeration Migration: change reset-request endpoint to always 200; deploy.

7. No session invalidation on reset Migration: invalidate sessions on reset deploy; users may need to re-login.

8. Long-lived sessions, no revocation Migration: add session table with expires_at; daily cron to clean up; user-facing "log out all devices."

9. No 2FA Migration: optional 2FA for all users; required for admins.

10. No audit log Migration: add audit_log table; log every auth event.

For my app:

  • Current state
  • Risk priorities

Output:

  1. Migration order
  2. Per-issue plan
  3. User-impact mitigation

The migration discipline: **never ship a security fix that requires manual cleanup later**. Each fix should be self-contained and idempotent. Hashing existing tokens? Do it in a migration. Adding rate limits? Deploy with thresholds; observe; tighten.

## What Done Looks Like

A working auth-recovery setup:
- Reset tokens are CSPRNG-generated, 32+ bytes, base64url-encoded
- Tokens are hashed before storage; raw token only in email
- Reset response is identical for valid and invalid emails (no enumeration)
- Reset token TTL is 15 min; magic link TTL is 5-10 min
- Tokens are single-use; marked used atomically
- All sessions invalidated on password change
- Confirmation email sent after every reset
- Rate limits per IP and per email
- Audit log of every auth event
- Tests cover happy path, expiration, enumeration, rate limiting, open redirect
- Passkey support offered (passkey-first, password fallback)
- Email content includes expiration time, anti-phishing warnings

The proof you got it right: an attacker who knows a user's email cannot enumerate, cannot brute-force, cannot DoS the user's mailbox, cannot use leaked tokens, cannot persist via stale sessions. The flow looks identical from outside whether the email is real.

## See Also

- [Two-Factor Auth](two-factor-auth-chat.md) — the second factor on top of password
- [SSO & Enterprise Auth](sso-enterprise-auth-chat.md) — SAML / OIDC for enterprise customers
- [Social Login & OAuth](social-login-oauth-chat.md) — sign-in-with-Google/Microsoft
- [Email Template Implementation](email-template-implementation-chat.md) — transactional email
- [Email Deliverability](email-deliverability-chat.md) — reset email must arrive
- [Audit Logs](audit-logs-chat.md) — auth events into audit
- [Account Deletion & Data Export](account-deletion-data-export-chat.md) — companion account-management flow
- [Logging Strategy & Structured Logs](logging-strategy-structured-logs-chat.md) — auth events in logs
- [Rate Limiting & Abuse](rate-limiting-abuse-chat.md) — companion abuse-protection layer
- [VibeReference: Auth Providers](https://vibereference.dev/auth-and-payments/auth-providers) — Clerk / Auth0 / WorkOS / Supabase / Better Auth comparison
- [VibeReference: Passkeys](https://vibereference.dev/auth-and-payments/passkeys) — passkey vendor support