Get listed

Secure authentication: cookie session, regenerate, named MFA

A teal passkey fob beside a coral password slip.

Secure authentication is proving who the caller is, then ending that proof when they leave or you revoke it.

NIST’s current authenticator guidance is the vocabulary for passwords, MFA, and session lifetime. Your app still has to implement rate limits, reset tokens that expire, and a session you can delete.

The usual mistake is a long-lived JWT in localStorage and a password policy copied from 2013. Length and a breach check beat complexity theater. A cookie session beats a token you cannot revoke.

This page is the authenticator choices that still match NIST, and the session design that belongs next to them.

NIST SP 800-63B-4 is dated July 2025 on the publication PDF. The nist.gov record lists 1 August 2025. Single-factor passwords SHALL be at least 15 characters.

Pair this page with cookie sessions for the flags and the store, JWT in Express for the verifier you should usually skip, and CSRF for the cookie POST the user did not mean. Reused passwords are credential stuffing.

Login is a pipeline. The password or passkey proves a factor, then regenerate mints a new __Host-session before any step-up you can name.

SecureCoding

A session is a random sid the browser holds and a row you can delete. express-session 1.19.0, updated 22 January 2026, still names the cookie connect.sid and omits SameSite unless you set it. Name it __Host-session. httpOnly, secure, sameSite: "lax", path: "/", no Domain. Redis or Postgres is the store. MemoryStore is the development default. The readme says do not use it in production.

A JWT is a signed claims set. RFC 7519 names the fields. RFC 8725 is the BCP. Logout is not a feature of the format. The blob is valid until exp unless you also keep a jti denylist. That denylist is a session store with extra steps. jose 6.2.10 published on 21 August 2026. Reach for it when a peer service must accept the blob without your Redis. Pin algorithms, aud, iss, and exp. The JWT page is that verifier.

A JWT in a cookie is still a cookie. You inherited CSRF. A JWT in localStorage is a portable secret for the whole TTL. XSS reads it and leaves. An HttpOnly cookie cannot be read by script. The script can still call your API while the tab is open. That is an XSS problem, not a reason to move the secret into JavaScript storage.

Regenerate once the factor holds

Session fixation is the pre-auth id becoming the post-auth id. A planted connect.sid, a shared kiosk, or a sibling host should not become "logged in as you" when the password or passkey succeeds. express-session documents req.session.regenerate for this. Run it once that check accepts. Then write userId on the new row. save before you redirect.

import argon2 from "argon2";

app.post("/login", async (req, res) => {
 const { email, password } = req.body;
 const user = await findUserByEmail(email);
 const ok = user && await argon2.verify(user.passwordHash, password);
 if (!ok) {
 req.log.info({ email, result: "401" }, "login");
 res.status(401).send("Unauthorized");
 return;
 }
 req.session.regenerate((err) => {
 if (err) {
 res.status(500).send("Unavailable");
 return;
 }
 req.session.userId = user.id;
 req.session.createdAt = Date.now();
 req.session.save((saveErr) => {
 if (saveErr) {
 res.status(500).send("Unavailable");
 return;
 }
 res.redirect("/app");
 });
 });
});

Destroy on logout. Rotate again on password change and on a privilege change. Idle timeout is a sliding lastSeen. Absolute timeout is createdAt plus 12 hours. Those two clocks are different jobs. 63B-4’s AAL2 table suggests an overall reauth cap around 24 hours and an idle hint around 1 hour for federal verifiers. Pick numbers you can explain. Write them down.

Password rules NIST actually wrote

SP 800-63B-4 and the nist.gov publication record. The length rules are SHALL, not blog folklore:

  • Single-factor passwords: minimum 15 characters.
  • Passwords used only as one factor in MFA: minimum 8 characters.
  • Permit at least 64 characters.
  • Accept printing ASCII and space. Unicode SHOULD be accepted. Count each code point as one character.
  • SHALL NOT impose composition rules such as "one upper, one digit, one symbol."
  • SHALL NOT prompt for knowledge-based questions as a password stand-in.

Hash with a slow scheme. argon2 0.45.1 published on 21 July 2026. Use argon2id. npm page for that date. Store only the hash. Compare with argon2.verify. Never write the plaintext on a verify miss.

import argon2 from "argon2";

const passwordHash = await argon2.hash(password, { type: argon2.argon2id });
const match = await argon2.verify(passwordHash, password);

Identifiers stay passwordHash, email, userId, and sid. A complexity meter that demands A1! and caps at 12 characters is the opposite of 63B-4. Block a known-breached secret if you can query a k-anonymity API. That is stuffing defense, not a composition rule.

MFA you can name

"We have MFA" is not a control. Name the authenticator. 63B-4 AAL2 requires two distinct factors and SHALL offer at least one phishing-resistant option. Passwords are not phishing-resistant. OTP is not. Out-of-band is not. Email SHALL NOT be used for out-of-band. Use of the PSTN for SMS or voice is a restricted authenticator; the CSP must offer an unrestricted alternative and a migration plan. 1.3 and 3.2.9 from the page I am not citing a vendor blog that says SMS is banned from AAL2.

WebAuthn is the API under passkeys. W3C document: Web Authentication Level 3, Candidate Recommendation Snapshot, 26 May 2026. It stays a CR at least until 23 June 2026. Use the shipped navigator.credentials.create and get. Treat the CR as late-stage, not as law.

@simplewebauthn/server 13.3.2 published on 24 June 2026. Docs for ^13 export generateRegistrationOptions, verifyRegistrationResponse, generateAuthenticationOptions, and verifyAuthenticationResponse. Store credential.id, publicKey, and counter. Check expectedOrigin and expectedRPID.

import {
 generateRegistrationOptions,
 verifyRegistrationResponse,
} from "@simplewebauthn/server";

const rpID = "app.example.com";
const origin = "https://app.example.com";

app.get("/webauthn/register/options", async function options(req, res) {
 const user = await findUser(req.session.userId);
 const options = await generateRegistrationOptions({
 rpName: "Example App",
 rpID,
 userName: user.email,
 attestationType: "none",
 authenticatorSelection: {
 residentKey: "preferred",
 userVerification: "preferred",
 },
 });
 req.session.webauthnReg = options.challenge;
 res.json(options);
});

app.post("/webauthn/register/verify", async (req, res) => {
 const verification = await verifyRegistrationResponse({
 response: req.body,
 expectedChallenge: req.session.webauthnReg,
 expectedOrigin: origin,
 expectedRPID: rpID,
 });
 if (!verification.verified || !verification.registrationInfo) {
 res.status(400).send("Unverified");
 return;
 }
 const { credential } = verification.registrationInfo;
 await savePasskey(req.session.userId, {
 id: credential.id,
 publicKey: credential.publicKey,
 counter: credential.counter,
 });
 res.json({ verified: true });
});

After a successful assertion, run the same regenerate path you use for a password. The passkey proved the user. The session id still needs a new row. TOTP is a named fallback when a device has no platform authenticator. Store the secret encrypted. Do not print it in a welcome email after the first view.

Stuffing is a reused password

Stuffing is a password that already leaked elsewhere, replayed at your /login. It is not a guessing loop against one mailbox. The sibling stuffing page is older and walks rainbow tables. The control in 2026 is still: do not invite a reused secret, and do not make /login a cheap oracle.

express-rate-limit 8.6.2 published on 4 August 2026. Cap attempts per account and per IP. Same 401 body for a missing user and a bad password so you do not confirm mailboxes. Offer a passkey so a leaked password is not enough. Check a breach corpus on set-password and on login if the product budget allows the lookup.

import { rateLimit } from "express-rate-limit";

const loginLimit = rateLimit({
 windowMs: 15 * 60 * 1000,
 limit: 10,
 standardHeaders: "draft-8",
 legacyHeaders: false,
});

app.post("/login", loginLimit, loginHandler);

Ten tries per 15 minutes is a starting cap, not a standard. Tune it when support tickets say a shared NAT is colliding. A CAPTCHA is friction. It is not a substitute for a second factor.

Keep secrets out of the log

A09:2025 is Security Logging and Alerting Failures. The Top 10 intro stresses alerting, not a diary. Login events belong in the log: email, result, ip, userAgent. The secret does not. Neither does Authorization, a session id you can replay, or a TOTP seed.

import pino from "pino";

const log = pino({
 redact: {
 paths: [
 "password",
 "newPassword",
 "req.body.password",
 "req.headers.authorization",
 "req.headers.cookie",
 ],
 censor: "[redacted]",
 },
});

Error pages stay boring. 500 Unavailable to the browser. The stack stays in the store you already restrict. Do not attach err.config.headers from an HTTP client that replayed a bearer token.

Prove the login path

You are proving your own handler. You are not stuffing a third-party site.

  1. Log in. DevTools, Application, Cookies. Expect __Host-session, HttpOnly, Secure, Lax or Strict, Path /, no Domain.
  2. Note the cookie value. Log out. Log in again. The value must change. If it does not, regenerate never ran.
  3. POST /login with a JSON body that includes a dummy password. Read your last log line. If the dummy appears, the redact list is wrong.
  4. Copy as cURL a state-changing request. Add Sec-Fetch-Site: cross-site. Expect 403. A 200 means the cookie POST has no CSRF check.
  5. If you shipped WebAuthn, register a passkey on your own account and sign in with it. Confirm a new sid after the assertion.
rg -n "connect\\.sid|session\\.regenerate|req\\.body|argon2id|generateRegistrationOptions" \
 --glob '!node_modules'

Questions we keep getting

Is SMS a second factor I can ship?

You can ship it as a restricted authenticator if you also offer a phishing-resistant option and you accept the PSTN risk 63B-4 names. Email is not an out-of-band channel under that document. Prefer a passkey. Keep TOTP as the named fallback.

Do I still need CSRF if I switch to a Bearer JWT?

The browser will not attach Authorization by itself. CSRF falls out of scope. You inherited storage and revocation instead. For a first-party cookie app, keep the session and the CSRF check. Do not add a JWT to feel current.

Does regenerate break remember-me?

No. Remember-me is a second, longer-lived secret in __Host-remember, hashed in the store, rotated, and exchanged for a new __Host-session after regenerate. Do not stretch session maxAge to thirty days to fake it.