Authentication is how your app answers one question: is this person who they claim to be? There are four building blocks you'll meet, and most of the trouble comes from mixing them up or wiring one of them incorrectly. Get the model straight first, then the details fall into place.
- OAuth 2.1 / OIDC handles delegated auth. The user proves their identity to an identity provider, and your app receives an assertion that they did.
- Passkeys / WebAuthn are passwordless and phishing-resistant, built on public-key cryptography.
- JWTs are a token format for carrying claims. A building block, not a protocol on their own.
- Session cookies are the original approach, and still a perfectly good one.
OAuth 2.1
OAuth 2.1 is essentially OAuth 2.0 with the known-bad parts removed and the good practices made mandatory:
- Authorisation Code plus PKCE is required for every client. The implicit flow is gone.
- Refresh tokens rotate, one use each.
- The password grant is gone.
The mistakes cluster in predictable places:
- Open redirect through
redirect_uri. Register exact URIs. No wildcards. - A missing
stateparameter. It's your CSRF defence on the callback. Generate it randomly and validate it on return. - Tokens leaking via the referrer header. Set
Referrer-Policy: no-referrer. - Tokens in
localStorage, where any XSS can read them. UseHttpOnlycookies or a backend-for-frontend instead.
Passkeys
Passkeys are worth understanding because they remove an entire category of attack. The flow is two halves:
- Registration. The device generates a key pair, sends the public key to your server, and keeps the private key locked to the device.
- Authentication. The server sends a challenge, the device signs it, the server verifies the signature.
Why it matters: the credential is bound to your origin, so phishing doesn't work. Every site gets a unique credential, so reuse doesn't exist. And a breach of your server yields nothing useful, because you only ever stored public keys.
Two things to plan for: support passwords through the transition, and design account recovery up front (backup codes, trusted contacts) so a lost device isn't a lost account.
JWTs
A JWT is signed JSON in three parts: header.payload.signature. The failure modes are well known and worth memorising.
Algorithm confusion. If your verification trusts the alg field from the token itself, an attacker sets alg: none or signs with your public key. Pin the algorithm:
const { payload } = await jwtVerify(token, publicKey, {
algorithms: ["RS256"],
});
No expiry. Set a short one. Minutes, for access tokens.
Secrets in the payload. The payload is Base64-encoded, not encrypted. Anyone can read it.
No way to revoke. JWTs are stateless by design. If you need instant revocation, you need short-lived tokens backed by server-side state.
Session cookies
The server creates a session, hands the browser a session ID as a cookie, and the browser returns it on every request. Three flags do the heavy lifting:
| Flag | Purpose |
|---|---|
HttpOnly | JavaScript can't read it |
Secure | Sent over HTTPS only |
SameSite=Lax | Mitigates CSRF |
Which approach fits which app:
| Scenario | Reach for |
|---|---|
| Traditional server-rendered app | Session cookies |
| SPA with a same-origin API | Session cookies or a BFF |
| SPA calling third-party APIs | JWT |
| Microservices | Short-lived JWT |
| Instant revocation required | Server-side sessions |
Add MFA on top
Whatever you choose, layer multi-factor on it.
- TOTP (Authy, Google Authenticator) is easy and widely supported.
- WebAuthn / FIDO2 hardware keys are the strongest, and phishing-resistant.
- SMS and email are weak to SIM swapping. Keep them as a fallback, not a primary factor.
And don't stop at the login screen. Require step-up auth for sensitive operations too, so stealing a session isn't the same as owning the account.
