Authentication as a Lifecycle
Authentication is not a login form: it is registration, credential storage, login, second factors, session establishment, re-authentication, recovery, device management and revocation — and attackers reliably target the least-defended stage, which is almost always recovery.
Frame the problem
Security starts with a concrete asset, attacker capability and trust crossing.
Nine stages, and where each one fails
Teams design the login form carefully and inherit the rest. The result is systems with excellent password hashing and a password reset flow that emails a token valid for seven days, or with hardware-key MFA and a support process that disables it on request.
Model the whole lifecycle and ask the same questions at each stage: what credential is presented, what does success grant, how long does it last, who can trigger it, and what does the attacker gain by defeating exactly this stage?
- Registration — creating the account. Fails to: user enumeration ("that email is taken"), automated bulk signup, unverified email letting someone claim an address they do not own.
- Credential storage — how the secret is kept. Fails to: fast hashes, missing salts, plaintext in logs. See Password Storage.
- Login — presenting the credential. Fails to: credential stuffing, brute force, timing differences that reveal whether an account exists.
- Second factor — proving possession. Fails to: SMS interception, real-time phishing proxies, push-notification fatigue.
- Session establishment — turning a successful login into ongoing access. Fails to: session fixation, tokens in URLs, missing cookie attributes. See Sessions.
- Re-authentication (step-up) — proving it is still you before something dangerous. Usually absent entirely, which is why a stolen session can change the email address.
- Recovery — the path for users who lost the credential. Fails to: everything. This is reliably the weakest stage.
- Device and session management — what the user can see and revoke. Fails to: no visibility, no bulk revoke, sessions that survive a password change.
- Deprovisioning — ending access. Fails to: SSO disabled but API tokens still valid, OAuth grants that outlive the account.
Recovery is an alternate login with weaker controls
Every account recovery flow is, by construction, a way to obtain access without the credential. If it is weaker than the login it replaces — and it usually is — then it *is* the authentication strength of the system, regardless of what the login does.
The common failures are consistent: reset tokens that are long-lived, reusable, or predictable; reset links that do not invalidate on use or on a subsequent password change; recovery that skips MFA entirely, so a hardware key protects nothing; security questions whose answers are public facts; and a support process where a convincing phone call is sufficient.
A defensible recovery flow treats itself as a high-risk operation: a single-use token with a lifetime measured in minutes, bound to the account and invalidated by any subsequent credential change; MFA still required if MFA is enrolled; all existing sessions revoked on completion; and a notification to the previously-known contact that cannot be suppressed by the person doing the reset. Where the account is high value, add a delay with a cancel link — 24 hours during which the real owner can stop it — which converts a silent takeover into a race the defender can win.
1async function requestReset(email: string) {2 const user = await users.byEmail(email)3 // Always respond identically and in similar time, whether or not the account exists.4 if (user) {5 const raw = crypto.randomBytes(32).toString('base64url') // 256 bits of entropy6 await resets.insert({7 userId: user.id,8 tokenHash: sha256(raw), // store the hash: a leaked table must not grant resets9 expiresAt: Date.now() + 15 * 60_000,10 usedAt: null,11 requestedFromIp: currentIp(),12 })13 await mail.send(user.email, resetLink(raw))14 }15 return { ok: true, message: 'If that address has an account, we sent a link.' }16}17 18async function completeReset(raw: string, newPassword: string) {19 const row = await resets.byTokenHash(sha256(raw))20 if (!row || row.usedAt || row.expiresAt < Date.now()) throw new InvalidReset()21 22 await db.transaction(async (tx) => {23 await tx.resets.markUsed(row.id) // single use24 await tx.resets.invalidateAllFor(row.userId) // sibling tokens die too25 await tx.users.setPassword(row.userId, await hashPassword(newPassword))26 await tx.sessions.revokeAll(row.userId) // every existing session ends27 })28 // MFA enrolment is NOT cleared here: a reset proves email control, not possession.29 await mail.send(await emailFor(row.userId), 'Your password was changed')30 await audit.log('password.reset.completed', { userId: row.userId, ip: currentIp() })31}Enumeration, step-up and revocation
User enumeration is the quiet one. If registration says "email already in use", login says "no such user" for one address and "wrong password" for another, or reset takes 40 ms for unknown accounts and 300 ms for known ones, an attacker can build a list of your real users before trying a single password. That list makes credential stuffing dramatically more efficient. The defence is uniform responses and uniform timing on all three flows, which usually costs some user-experience clarity — a real trade worth making for accounts where membership itself is sensitive, and worth reconsidering for a consumer product where it mostly costs support tickets.
Step-up authentication is the control that limits what a stolen session can do. Changing the email address, adding a payment method, disabling MFA, creating an API token and adding an OAuth application are all account-takeover primitives, and each should require re-presenting a credential regardless of session age. This is the single cheapest defence against session theft, and it is missing from most applications.
Revocation is what turns a compromise into an incident with an end. The user should be able to see active sessions with device and location, revoke one or all, and have that take effect immediately rather than at token expiry. A password change should revoke every other session by default. If your token design cannot do this — see JWT Failure Modes — that is a design consequence you chose, and it should be a conscious one.
Key points
- Authentication is nine stages, and attackers pick the weakest — which is almost always recovery.
- A recovery flow weaker than login *is* your authentication strength; make it single-use, short-lived, MFA-preserving and session-revoking.
- Uniform responses and timing on registration, login and reset prevent building a list of your real users.
- Step-up re-authentication before account-takeover primitives is the cheapest defence against session theft.
- Revocation must be immediate and user-visible; a design that cannot revoke has chosen that, and should have chosen it deliberately.
Boundary control exercise
This lesson uses the shared boundary-control exercise.
Follow the attack
Safe conceptual simulation: capability → missing control → crossed boundary → asset impact.
- 1Attacker → enumerate: use registration, login and reset responses to confirm which addresses have accounts.
- 2Confirmed accounts → credential stuffing with passwords from unrelated breaches.
- 3Failure → recovery: trigger a password reset and attack the token, the email account, or the support process instead.
- 4Access → persistence: add an API token, add an OAuth app, or change the recovery email so that fixing the password changes nothing.
- Full control of the account and everything it can reach, including data belonging to others where the account has sharing or admin rights.
- Persistence beyond remediation if the attacker created a second credential before being detected.
- For an admin account, the blast radius is the whole tenant or the whole system.
Defend, detect, recover
One prevention is a single point of security failure. Layer it and make failure observable.
- • Use a well-maintained identity provider or library for the credential and factor handling; build only the parts specific to your domain.
- • Harden recovery to the same standard as login, including retaining MFA requirements.
- • Require step-up authentication for every account-takeover primitive.
- • Revoke all sessions and API tokens on credential change; notify the user through a channel the attacker has not just changed.
- • Alert on bursts of failed logins across many accounts from few sources (stuffing) and many attempts on one account (targeted).
- • Alert on recovery requests at unusual rates, and on any recovery completed from a device and location never seen for that account.
- • Alert on the persistence primitives: new API token, new OAuth grant, changed recovery email, disabled MFA.
- • Revoke every session and token for the account, then force credential re-establishment through a verified channel.
- • Check for persistence added during the compromise window before declaring the account clean.
- • Notify the user out of band, since in-band channels may now be attacker-controlled.
- • A compromised email account defeats most recovery designs, because email is the root of the recovery tree for nearly everything.
- • Support processes remain a human-judgment path and are the branch attackers move to when the technical ones close.
- • Users reuse passwords across services no matter what you do; assume the credential is known and design accordingly.