Cyber security · Auth & Sessions

Passkeys, MFA, Sessions: Modern Authentication Done Right

Stop account takeovers with practical defaults and real examples.

Reading time: ~8–12 min
Level: All levels
Updated:

Most account takeovers aren’t “zero-days” — they’re predictable: phishing, credential stuffing, weak recovery, and overly-permissive sessions. This guide shows how to build modern authentication with passkeys, sensible MFA, and session handling that doesn’t quietly leak access. You’ll get concrete defaults, edge-case rules, and implementation patterns you can lift into any stack.


Quickstart

If you want the fastest security improvement per minute, do these in order. Each item is designed to reduce account takeover risk without turning your login into a support nightmare.

1) Make passkeys the “happy path”

Passkeys (WebAuthn/FIDO2) are phishing-resistant and remove password reuse from the equation. Offer them at sign-up and as a post-login upgrade.

  • Allow “Create a passkey” right after email verification
  • Support multiple passkeys per account (phone + laptop)
  • Keep a fallback path (password or magic link) for edge devices

2) Turn MFA into “step-up”, not “every time”

The goal is to block risky sign-ins and sensitive actions — not to annoy trusted sessions. Use MFA when the risk or impact is high.

  • Require MFA for password logins (or high-risk sign-ins)
  • Require re-auth / MFA for billing, password change, recovery changes
  • Prefer app-based TOTP or security keys; treat SMS as a last resort

3) Lock down sessions (cookie flags + rotation)

Session management is where “we’re secure” quietly turns into “why is there a strange login?” Default to short-lived access and predictable revocation.

  • Use HttpOnly, Secure, and SameSite cookies for web sessions
  • Rotate session IDs on login and privilege changes (prevent fixation)
  • List active sessions/devices and allow “log out of all”

4) Harden recovery before you ship

Attackers love recovery flows because they bypass your strongest controls. Treat recovery like a privileged pathway.

  • Use short-lived, single-use reset tokens
  • Require recent auth / MFA to change email, phone, or recovery methods
  • Notify the user on recovery events and new device logins
A practical default

If you can only enforce one thing today: make passkeys available and tighten session cookies. That combo blocks a huge amount of phishing + “stolen cookie” abuse with minimal user friction.

Overview

“Modern authentication” isn’t one feature — it’s a system. Users authenticate once, but attackers probe every edge: sign-up, password reset, “remember me”, OAuth callbacks, and long-lived sessions. This post walks through a practical stack that holds up in production:

What you’ll build (conceptually)

Layer Goal Practical default
Passkeys Phishing-resistant login Offer at sign-up + “upgrade” after login
MFA / Step-up Block risky sign-ins & high-impact actions TOTP/security keys; avoid SMS-only
Sessions Keep users signed in safely HttpOnly+Secure cookies, rotation, revocation
Recovery Regain access without bypassing security Short-lived tokens + re-auth to change recovery

Throughout the guide, “done right” means: secure by default, observable (auditable), and predictable under attack. You don’t need a complex identity platform to get these benefits — you need clear rules and consistent implementation.

Think in attacker workflows

Attackers rarely “break crypto”. They reuse passwords, steal sessions, and abuse recovery. If you design around those paths, you’ll stop most real-world takeovers.

Core concepts

Before implementation details, you want the right mental model. Most auth bugs happen when a team mixes up authentication (“who are you?”) with sessions (“you already proved it — here’s your ticket”), or when recovery shortcuts bypass the stronger controls.

Authentication vs sessions vs authorization

Authentication

Prove identity (once). This can be passkeys, passwords, SSO, or magic links.

  • Goal: stop impostors
  • Threats: phishing, credential stuffing, SIM swap
  • Output: a session or token

Sessions

Maintain access over time. Sessions are the “bearer tickets” attackers try to steal.

  • Goal: safe continuity + revocation
  • Threats: cookie theft, fixation, long-lived tokens
  • Output: cookie/token used on each request

Passkeys in one paragraph

Passkeys are credentials backed by public-key cryptography. The private key stays on the user’s device (or secure element) and the website/server verifies a signature for each login. Because the signature is bound to the site’s origin (and usually requires device user verification), passkeys are strongly resistant to phishing and replay.

MFA isn’t one thing: pick the right factors

“MFA” means “more than one factor” — but not all factors are equal. For example, SMS can be better than nothing, but it’s also vulnerable to SIM swaps and social engineering. Prefer phishing-resistant factors when possible.

Method Phishing resistance User friction When it’s a good fit
Passkeys (WebAuthn) High Low Primary login for web/mobile apps
Security keys (FIDO2) High Medium Admins, high-value accounts, enterprise
TOTP app Medium Medium Step-up MFA for sensitive actions
SMS OTP Low–Medium Medium Fallback only; avoid as “the only factor”

Session types: cookies, opaque tokens, and JWTs

Sessions come in a few common flavors. The important thing is not the buzzword — it’s whether you can expire, rotate, and revoke them reliably.

Cookie session (web)

Best default for browser apps. Store an opaque session ID in an HttpOnly cookie and keep the session server-side.

  • Easy revocation (“delete session in DB”)
  • Safer against XSS (HttpOnly)
  • Needs CSRF awareness (SameSite helps)

Bearer tokens (APIs/mobile)

Often used for APIs. If you use JWTs, treat them like bearer tickets: keep lifetimes short and rotate refresh tokens.

  • Short-lived access tokens (minutes)
  • Refresh token rotation (days/weeks)
  • Store tokens safely (avoid browser localStorage)
The hidden truth about “stateless”

If you need “log out everywhere”, compromised-session remediation, or device management, you will end up with state anyway. Build with revocation in mind from day one.

Step-by-step

This is a pragmatic build order for “Passkeys, MFA, Sessions: Modern Authentication Done Right”. You can adopt it incrementally: ship a secure baseline, then add passkeys + step-up, then harden recovery and operations.

Step 1 — Define your threat model and “high-risk” actions

You don’t need a 30-page document. You need a short list of what attackers want and where your app is weakest.

  • Account takeover: phishing, credential stuffing, device theft, session theft
  • High-impact actions: password/email change, MFA changes, export data, billing, API key creation
  • Constraints: web vs mobile, SSO requirements, offline access, support capacity

Step 2 — Implement passkeys (WebAuthn) as the preferred login

The cleanest roll-out is: keep your existing login, then add a post-login prompt to “Create a passkey”. Once users have a passkey, you can offer “Sign in with passkey” as the default button.

Registration flow (high level)

  • Server creates a challenge + relying party config
  • Browser calls WebAuthn APIs to create the credential
  • Server verifies attestation/assertion and stores the public key
  • Optional: require user verification (PIN/biometric)

Login flow (high level)

  • Server creates a challenge (per attempt) + allow-list credentials
  • Browser signs the challenge with the passkey
  • Server verifies signature + counters
  • Server creates a fresh session (rotate session ID)

The key rule: WebAuthn needs careful binary encoding/decoding (base64url), and every challenge should be one-time and short-lived. This client snippet shows the shape of a passkey registration call.

/**
 * Browser-side passkey (WebAuthn) registration (shape + safety notes)
 * - Your server must provide options, including a one-time challenge.
 * - Use base64url for binary fields (challenge, user.id, credential IDs).
 */
function b64urlToBytes(b64url) {
  const pad = "=".repeat((4 - (b64url.length % 4)) % 4);
  const b64 = (b64url + pad).replace(/-/g, "+").replace(/_/g, "/");
  const raw = atob(b64);
  return Uint8Array.from([...raw].map((c) => c.charCodeAt(0)));
}

async function createPasskey() {
  // 1) Fetch WebAuthn options from your server
  const opts = await fetch("/webauthn/register/options", { credentials: "include" }).then(r => r.json());

  // 2) Convert base64url fields to ArrayBuffers
  opts.publicKey.challenge = b64urlToBytes(opts.publicKey.challenge);
  opts.publicKey.user.id = b64urlToBytes(opts.publicKey.user.id);

  // 3) Create credential
  const cred = await navigator.credentials.create({ publicKey: opts.publicKey });

  // 4) Send attestation back to server for verification + storage
  await fetch("/webauthn/register/verify", {
    method: "POST",
    credentials: "include",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      id: cred.id,
      rawId: Array.from(new Uint8Array(cred.rawId)),
      type: cred.type,
      response: {
        attestationObject: Array.from(new Uint8Array(cred.response.attestationObject)),
        clientDataJSON: Array.from(new Uint8Array(cred.response.clientDataJSON))
      }
    })
  });
}
Passkey UX that actually increases adoption
  • Prompt after a successful login (“Upgrade your sign-in with a passkey”)
  • Allow multiple devices (users expect it)
  • Explain the benefit in plain language: “protects against phishing”

Step 3 — Add MFA as “step-up” (and protect sensitive changes)

MFA is most effective when it’s targeted: high-risk sign-ins and high-impact actions. If a user logs in with a passkey (phishing-resistant), you can often reduce prompts while keeping strong security.

Good step-up triggers

  • New device or unusual location/IP reputation shift
  • Password-based login (especially after many failed attempts)
  • Changing email/password/MFA settings
  • Viewing/exporting sensitive data
  • Creating API keys or admin actions
Don’t let recovery bypass MFA

If “reset password” disables MFA or allows changing recovery methods without a recent verification, attackers will aim at recovery first. Recovery needs its own guardrails: short-lived tokens, notifications, and re-auth before changing security-critical settings.

Step 4 — Sessions: choose defaults that are safe in the browser

For browser apps, the best default is a server-side session keyed by a cookie that is HttpOnly (not readable by JavaScript), Secure (HTTPS only), and SameSite (reduces CSRF). Rotate session IDs on login and privilege elevation.

Session cookie defaults (web)

Setting Recommended Why it matters
HttpOnly true Reduces impact of XSS (cookie not accessible to JS)
Secure true Prevents cookie over plain HTTP
SameSite Lax (or Strict for some apps) Mitigates CSRF for typical navigation patterns
Path / Ensures consistent scoping
Max-Age Short-ish + rolling Limits stolen cookie window; supports “keep me signed in” safely

Here’s a minimal Express example that sets safe cookie defaults and rotates sessions on login. (You can mirror the same ideas in any framework.)

import express from "express";
import session from "express-session";

const app = express();

// If behind a reverse proxy (common in production), trust it so Secure cookies work correctly.
app.set("trust proxy", 1);

app.use(session({
  name: "__Host-sid",           // __Host- prefix helps enforce safer cookie scope in modern browsers
  secret: process.env.SESSION_SECRET,
  resave: false,
  saveUninitialized: false,
  cookie: {
    httpOnly: true,
    secure: true,               // HTTPS only
    sameSite: "lax",            // mitigate CSRF in most common patterns
    path: "/",
    maxAge: 1000 * 60 * 60 * 8  // 8 hours (adjust to your risk tolerance)
  }
}));

app.post("/login", async (req, res) => {
  // ... verify passkey/password/MFA here ...

  // Prevent session fixation: issue a fresh session ID on successful login.
  req.session.regenerate((err) => {
    if (err) return res.status(500).send("session error");
    req.session.userId = "user_123";  // set minimal identity + auth metadata
    req.session.authTime = Date.now();
    res.status(204).end();
  });
});

Step 5 — Refresh tokens: store safely, rotate, and revoke

If you need API access tokens (mobile, SPA calling APIs, integrations), treat refresh tokens as high-value secrets. Use rotation (each refresh invalidates the previous token) and store only a hash server-side. That way, a database leak doesn’t instantly become “everyone is logged in forever”.

Recommended token strategy

  • Access token TTL: minutes
  • Refresh token TTL: days/weeks (bounded)
  • Rotate on every refresh; revoke token “family” on suspicious reuse
  • Hash refresh tokens at rest (don’t store raw values)

Operational must-haves

  • Session/device list with “log out of all devices”
  • Invalidate sessions after password/MFA changes
  • Alert on new device sign-in and recovery events

This Python example demonstrates the core pattern: generate a refresh token, store only a hash, rotate it on use, and make “revoke all” feasible.

import hashlib
import hmac
import secrets
from datetime import datetime, timedelta, timezone

# Server secret used as a pepper for token hashing (keep it out of the DB).
TOKEN_PEPPER = b"replace-with-env-secret"

def token_hash(raw_token: str) -> str:
  # HMAC makes hashes resistant to rainbow tables and ties validity to your server secret.
  mac = hmac.new(TOKEN_PEPPER, raw_token.encode("utf-8"), hashlib.sha256).hexdigest()
  return mac

def issue_refresh_token(user_id: str) -> dict:
  raw = secrets.token_urlsafe(48)  # send this to the client ONCE
  now = datetime.now(timezone.utc)
  record = {
    "user_id": user_id,
    "token_hash": token_hash(raw),
    "issued_at": now.isoformat(),
    "expires_at": (now + timedelta(days=30)).isoformat(),
    "revoked": False
  }
  # Save record in DB (token_hash only). Never store `raw`.
  return {"refresh_token": raw, "record": record}

def rotate_refresh_token(old_raw: str, user_id: str) -> dict:
  # 1) Look up old token by hash
  old_h = token_hash(old_raw)
  db_row = db_find_refresh_token(user_id=user_id, token_hash=old_h)  # your DB function
  if not db_row or db_row["revoked"]:
    raise ValueError("invalid token")

  # 2) Enforce expiration
  if datetime.fromisoformat(db_row["expires_at"]) < datetime.now(timezone.utc):
    raise ValueError("expired token")

  # 3) Revoke old token and issue a new one (rotation)
  db_revoke_refresh_token(db_row["id"])
  return issue_refresh_token(user_id)

# Tip: if you detect the same old token used twice, revoke the entire "family"
# (all refresh tokens for that user/session) and force re-authentication.

Step 6 — Recovery and “account change” rules

Your recovery policy should be explicit and conservative. A user who can change their email without re-auth essentially hands ownership to anyone with a stolen session. Treat security-critical settings as privileged: require recent auth and/or step-up.

Rules that prevent most recovery abuse

  • Reset tokens are single-use and expire quickly (e.g., 15–60 minutes)
  • Re-auth required to change: email, password, MFA methods, recovery addresses
  • Notify the user on recovery attempts and completion
  • Invalidate existing sessions after a recovery-driven password change
  • Rate limit recovery endpoints aggressively (per IP + per account + per device fingerprint)
A safe “remember me” implementation

“Remember me” should not mean “one cookie forever”. Instead: keep the primary session moderate (hours), and extend with a separate, revocable long-lived token that rotates and is tied to a device record.

Common mistakes

These are the real reasons “we added MFA” still ends up with account takeovers. The fixes are usually simple — but only if you name the failure mode clearly.

Mistake 1 — Treating passkeys like a “nice-to-have”

If passkeys are buried in settings, adoption stays low and passwords remain the primary target.

  • Fix: add passkey enrollment right after login (a gentle prompt, not a hard wall).
  • Fix: support multiple passkeys per account and show device names/last used.

Mistake 2 — SMS-only MFA

SMS can be intercepted or socially engineered. It’s better than nothing, not your endgame.

  • Fix: make TOTP or security keys the primary MFA options.
  • Fix: if you must support SMS, use it as a fallback + add extra monitoring/rate limits.

Mistake 3 — Long-lived, non-revocable sessions

A stolen cookie/token should not equal “access for weeks”.

  • Fix: shorten access lifetimes; rotate identifiers on login and privilege changes.
  • Fix: implement session/device lists and server-side revocation.

Mistake 4 — Storing tokens in browser localStorage

XSS turns localStorage into a token vending machine.

  • Fix: use HttpOnly cookies for browser sessions whenever possible.
  • Fix: if you need tokens, keep access tokens short-lived and store refresh tokens securely.

Mistake 5 — Recovery that bypasses security settings

If an attacker can reset the password and then disable MFA, you didn’t really have MFA.

  • Fix: require recent auth/step-up to change MFA and recovery options.
  • Fix: notify on recovery and new device logins; invalidate sessions after recovery.

Mistake 6 — Forgetting session fixation protection

If you don’t rotate session IDs at login, attackers can “preset” a session and ride it.

  • Fix: regenerate the session identifier after successful login.
  • Fix: regenerate again after privilege elevation (admin, billing, export, etc.).
Debugging checklist when auth feels “weird”
  • Do we rotate session IDs on login?
  • Can we revoke sessions instantly?
  • Does recovery require recent verification for sensitive changes?
  • Are cookies set with HttpOnly+Secure+SameSite?
  • Do we have rate limits and alerts on login/recovery?

FAQ

Are passkeys really better than passwords + MFA?

Yes, for most consumer and SaaS apps. Passkeys are designed to be phishing-resistant and remove password reuse. You can still keep MFA for step-up actions, but many users will experience fewer prompts and better security at the same time.

Should I remove passwords completely if I support passkeys?

Not immediately. A common strategy is “passkeys first” with a fallback (password or magic link) until adoption is high and your support team is comfortable with recovery flows. Removing passwords is possible, but only after you’ve hardened recovery and device onboarding.

What MFA method should I choose as a default?

Use TOTP (authenticator app) as the default, and offer security keys for high-value accounts. Treat SMS as a fallback for users who can’t use other methods, and add extra monitoring and rate limiting around it.

How long should sessions last?

Short enough that stolen sessions are limited, long enough that users aren’t angry. A typical pattern is an 8–24 hour web session (rolling) with explicit re-auth/step-up for sensitive actions. For APIs, use short-lived access tokens (minutes) with refresh token rotation.

Do I need CSRF protection if I use SameSite cookies?

Often, SameSite=Lax reduces CSRF risk for common flows — but don’t rely on it blindly. If you support cross-site POSTs, embedded contexts, or unusual auth flows, add CSRF tokens for state-changing actions. SameSite is a helpful default, not a complete strategy.

What’s the safest way to implement “Log out of all devices”?

Server-side session records. Keep a session table (or refresh token table) and mark all records revoked for the user. If you use JWTs without server-side state, you’ll need a revocation list or a short TTL plus rotation.

How do I stop credential stuffing?

Rate limit + detect + reduce password value. Add per-IP and per-account rate limiting, monitor failed logins, use breached-password checks if applicable, and shift users to passkeys so passwords become less relevant over time.

Cheatsheet

A scan-fast checklist for modern authentication: passkeys, MFA, and sessions. Use this for reviews, PR checklists, and “are we missing anything?” moments.

Passkeys

  • Offer passkey enrollment at sign-up or immediately after login
  • Support multiple passkeys per account (device list + last-used)
  • Challenges are one-time and short-lived
  • Origin/RP ID is correct and consistent across environments
  • Fallback path exists (but is monitored and rate limited)

MFA / Step-up

  • Use TOTP or security keys as primary MFA
  • Require MFA for password logins or risky sign-ins
  • Require recent auth / step-up for sensitive actions
  • Backup codes exist and are one-time use
  • Changing MFA/recovery requires re-auth

Sessions

  • Cookies: HttpOnly + Secure + SameSite set correctly
  • Rotate session ID on login and privilege changes
  • Server-side session or token revocation exists
  • List active sessions/devices + “log out everywhere”
  • Invalidate sessions on password/MFA/recovery changes

Recovery & Ops

  • Reset tokens are short-lived, single-use, and hashed at rest
  • Rate limits for login + MFA + recovery endpoints
  • Notify users on new device logins and recovery events
  • Audit log for auth events (login, logout, MFA changes)
  • Monitor anomalies (failed logins, token reuse, geo shifts)
If you’re reviewing a PR

Ask two questions: “Can we revoke access instantly?” and “Can recovery bypass our strongest controls?” If the answer is “no” to the first or “yes” to the second, you found the real risk.

Wrap-up

“Modern authentication done right” is a set of defaults you can defend under attack: passkeys for the happy path, MFA for step-up and risky sign-ins, and sessions you can rotate and revoke. If you implement the Quickstart items and keep recovery tight, you’ll remove most takeover paths attackers rely on.

Next actions (pick one)

  • Today: lock down session cookie flags + rotate session IDs on login
  • This week: add passkey enrollment after login and start measuring adoption
  • This sprint: implement step-up MFA for sensitive actions and harden recovery tokens
  • Ongoing: add a session/device list with revocation + auth event audit logs

Want to go deeper? The related posts below cover practical exploitation patterns, lightweight threat modeling, and security checklists that pair well with an auth hardening sprint.

Quiz

Quick self-check (demo). This quiz is auto-generated for cyber / security / auth.

1) What is the best “default” login experience for modern consumer/SaaS apps?
2) Which MFA approach usually balances security and UX best?
3) Which session practice most directly prevents session fixation?
4) What is a common way attackers bypass MFA in poorly designed systems?