Authentication is the foundation of trust in every web application. Yet it remains one of the most misunderstood, misimplemented, and frequently breached surfaces in modern software. A single misstep — an insecure redirect URI, a missing state parameter, a long-lived token without rotation — can unravel years of careful engineering.
This guide cuts through the noise. We'll explore OAuth 2.1, OpenID Connect (OIDC), and Passwordless Login with practical code, real-world flows, and the nuanced understanding you need to ship auth that holds up in production.
Whether you're integrating a third-party identity provider or rolling your own auth server, this post gives you the mental models and implementation patterns to do it right.
Table of Contents
- Why Authentication Is Hard
- OAuth 2.1: The Refined Standard
- OpenID Connect: Identity on Top of OAuth
- Passwordless Login: WebAuthn and Magic Links
- End-to-End Auth Flow: Putting It All Together
- Best Practices
- Common Mistakes
- 🚀 Pro Tips
- 📌 Key Takeaways
- Conclusion
Why Authentication Is Hard
Most auth vulnerabilities don't come from cryptographic failures — they come from implementation mistakes. Developers misread specs, copy outdated tutorials, or skip "optional" security parameters that turn out to be critical.
The threat landscape in 2026 has also evolved:
- Credential stuffing is automated and scaled with AI-assisted tooling.
- Session hijacking targets long-lived tokens and insecure cookie configurations.
- Phishing-resistant auth (like passkeys) is now a baseline expectation for high-assurance apps.
- Token theft via XSS remains a top attack vector against SPAs.
The good news: the standards have caught up. OAuth 2.1 consolidates years of security learnings. OIDC gives you a standardized identity layer. WebAuthn/Passkeys make phishing-resistant auth practical. Let's dig in.
OAuth 2.1: The Refined Standard
OAuth 2.1 is not a radically new protocol — it's a consolidation of OAuth 2.0 best practices into a single, opinionated specification. It removes deprecated grant types and mandates security requirements that were previously optional.
What Changed from OAuth 2.0
| Feature | OAuth 2.0 | OAuth 2.1 |
|---|---|---|
| Implicit Grant | Allowed | Removed |
| Resource Owner Password Credentials | Allowed | Removed |
| PKCE | Optional | Required (all public clients) |
| Redirect URI matching | Partial | Exact matching required |
| Bearer tokens in URLs | Allowed | Prohibited |
| Refresh token rotation | Optional | Required |
The Authorization Code Flow with PKCE
This is the gold-standard flow for any public client (SPAs, mobile apps) and is now required in OAuth 2.1.
Step 1: Generate a Code Verifier and Challenge
// utils/pkce.ts
export function generateCodeVerifier(): string {
const array = new Uint8Array(32);
crypto.getRandomValues(array);
return base64urlEncode(array);
}
export async function generateCodeChallenge(verifier: string): Promise<string> {
const encoder = new TextEncoder();
const data = encoder.encode(verifier);
const digest = await crypto.subtle.digest("SHA-256", data);
return base64urlEncode(new Uint8Array(digest));
}
function base64urlEncode(buffer: Uint8Array): string {
return btoa(String.fromCharCode(...buffer))
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=/g, "");
}
Step 2: Build the Authorization URL
// auth/authorize.ts
interface AuthParams {
clientId: string;
redirectUri: string;
scope: string;
authorizationEndpoint: string;
}
export async function initiateAuthFlow(params: AuthParams): Promise<void> {
const verifier = generateCodeVerifier();
const challenge = await generateCodeChallenge(verifier);
const state = generateCodeVerifier(); // reuse entropy generation for state
// Persist verifier and state securely (sessionStorage is acceptable here)
sessionStorage.setItem("pkce_verifier", verifier);
sessionStorage.setItem("oauth_state", state);
const url = new URL(params.authorizationEndpoint);
url.searchParams.set("response_type", "code");
url.searchParams.set("client_id", params.clientId);
url.searchParams.set("redirect_uri", params.redirectUri);
url.searchParams.set("scope", params.scope);
url.searchParams.set("state", state);
url.searchParams.set("code_challenge", challenge);
url.searchParams.set("code_challenge_method", "S256");
window.location.href = url.toString();
}
Step 3: Handle the Callback
// auth/callback.ts
interface TokenResponse {
access_token: string;
token_type: string;
expires_in: number;
refresh_token?: string;
id_token?: string;
}
export async function handleCallback(
tokenEndpoint: string,
clientId: string,
redirectUri: string
): Promise<TokenResponse> {
const params = new URLSearchParams(window.location.search);
const code = params.get("code");
const returnedState = params.get("state");
const error = params.get("error");
if (error) {
throw new Error(`Authorization error: ${error} — ${params.get("error_description")}`);
}
// Validate state to prevent CSRF
const storedState = sessionStorage.getItem("oauth_state");
if (!returnedState || returnedState !== storedState) {
throw new Error("State mismatch — potential CSRF attack detected.");
}
const verifier = sessionStorage.getItem("pkce_verifier");
if (!code || !verifier) {
throw new Error("Missing authorization code or PKCE verifier.");
}
// Clean up session storage
sessionStorage.removeItem("pkce_verifier");
sessionStorage.removeItem("oauth_state");
const response = await fetch(tokenEndpoint, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "authorization_code",
code,
redirect_uri: redirectUri,
client_id: clientId,
code_verifier: verifier,
}),
});
if (!response.ok) {
const err = await response.json();
throw new Error(`Token exchange failed: ${err.error_description}`);
}
return response.json();
}
Refresh Token Rotation
OAuth 2.1 mandates refresh token rotation — every time a refresh token is used, it must be invalidated and replaced.
// auth/refresh.ts
export async function refreshAccessToken(
refreshToken: string,
tokenEndpoint: string,
clientId: string
): Promise<TokenResponse> {
const response = await fetch(tokenEndpoint, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "refresh_token",
refresh_token: refreshToken,
client_id: clientId,
}),
});
if (!response.ok) {
// Token may be revoked or expired — force re-authentication
throw new Error("Refresh token invalid. Re-authentication required.");
}
const tokens: TokenResponse = await response.json();
// Always store the NEW refresh token — old one is now invalid
if (tokens.refresh_token) {
storeRefreshToken(tokens.refresh_token); // your secure storage abstraction
}
return tokens;
}
Note: If a refresh token is used twice (e.g., in a replay attack), a well-implemented authorization server should detect the reuse and revoke the entire token family, logging the user out for security.
OpenID Connect: Identity on Top of OAuth
OAuth 2.0/2.1 is an authorization protocol — it grants access to resources. But it says nothing about who the user is. That's where OpenID Connect (OIDC) comes in.
OIDC is a thin identity layer built on top of OAuth 2.0. It introduces:
- ID Token — a JWT containing claims about the authenticated user.
- UserInfo Endpoint — for fetching additional profile data.
- Discovery Document — a standard
.well-known/openid-configurationendpoint for auto-configuration. - Standard Scopes —
openid,profile,email,phone,address.
Auto-Discovery
// auth/discovery.ts
interface OIDCConfiguration {
issuer: string;
authorization_endpoint: string;
token_endpoint: string;
userinfo_endpoint: string;
jwks_uri: string;
end_session_endpoint?: string;
supported_scopes: string[];
response_types_supported: string[];
grant_types_supported: string[];
}
export async function discoverOIDCConfig(issuer: string): Promise<OIDCConfiguration> {
const discoveryUrl = `${issuer}/.well-known/openid-configuration`;
const response = await fetch(discoveryUrl);
if (!response.ok) {
throw new Error(`Failed to fetch OIDC discovery document from ${discoveryUrl}`);
}
return response.json();
}
Validating the ID Token
The ID token is a signed JWT. You must validate it — never trust it blindly.
// auth/validateIdToken.ts
import * as jose from "jose"; // npm install jose
interface IdTokenClaims {
iss: string; // Issuer
sub: string; // Subject (user ID)
aud: string | string[]; // Audience (must include your client_id)
exp: number; // Expiration
iat: number; // Issued at
nonce?: string; // Replay protection
email?: string;
name?: string;
picture?: string;
}
export async function validateIdToken(
idToken: string,
issuer: string,
clientId: string,
jwksUri: string,
nonce?: string
): Promise<IdTokenClaims> {
// Fetch the JSON Web Key Set from the authorization server
const JWKS = jose.createRemoteJWKSet(new URL(jwksUri));
const { payload } = await jose.jwtVerify(idToken, JWKS, {
issuer,
audience: clientId,
clockTolerance: "1 minute", // allow minor clock skew
});
const claims = payload as unknown as IdTokenClaims;
// Validate nonce to prevent replay attacks
if (nonce && claims.nonce !== nonce) {
throw new Error("Nonce mismatch — potential token replay attack.");
}
return claims;
}
Fetching User Profile
// auth/userinfo.ts
export async function fetchUserInfo(
userInfoEndpoint: string,
accessToken: string
): Promise<Record<string, unknown>> {
const response = await fetch(userInfoEndpoint, {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
if (!response.ok) {
throw new Error("Failed to fetch user info.");
}
return response.json();
}
OIDC Logout
Proper logout requires both local session termination and RP-initiated logout at the identity provider.
// auth/logout.ts
export function initiateLogout(
endSessionEndpoint: string,
idToken: string,
postLogoutRedirectUri: string
): void {
// Clear local session first
localStorage.clear();
sessionStorage.clear();
document.cookie.split(";").forEach((c) => {
document.cookie = c.replace(/=.*/, "=;expires=Thu, 01 Jan 1970 00:00:00 GMT;path=/");
});
// Redirect to IdP logout endpoint
const url = new URL(endSessionEndpoint);
url.searchParams.set("id_token_hint", idToken);
url.searchParams.set("post_logout_redirect_uri", postLogoutRedirectUri);
window.location.href = url.toString();
}
Passwordless Login: WebAuthn and Magic Links
Passwords are the weakest link in the auth chain. They're phished, reused, and breached en masse. Passwordless auth eliminates this entire category of risk.
Two patterns dominate in 2026:
- WebAuthn / Passkeys — phishing-resistant, hardware-backed credentials.
- Magic Links — one-time email links for low-friction, no-password login.
WebAuthn / Passkeys
The Web Authentication API (WebAuthn) allows browsers to authenticate users with platform authenticators (Face ID, Windows Hello, hardware security keys) using public-key cryptography.
Registration (Creating a Passkey)
// auth/passkey.ts
// Step 1: Fetch challenge from your server
async function fetchRegistrationOptions(userId: string): Promise<PublicKeyCredentialCreationOptions> {
const response = await fetch("/api/auth/passkey/register/begin", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ userId }),
});
const options = await response.json();
// Browser expects ArrayBuffers, not base64
options.challenge = base64ToArrayBuffer(options.challenge);
options.user.id = base64ToArrayBuffer(options.user.id);
return options;
}
// Step 2: Create the credential
export async function registerPasskey(userId: string): Promise<void> {
const options = await fetchRegistrationOptions(userId);
const credential = await navigator.credentials.create({
publicKey: options,
}) as PublicKeyCredential;
const attestation = credential.response as AuthenticatorAttestationResponse;
// Step 3: Send the attestation to your server for verification and storage
await fetch("/api/auth/passkey/register/complete", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
id: credential.id,
rawId: arrayBufferToBase64(credential.rawId),
type: credential.type,
response: {
clientDataJSON: arrayBufferToBase64(attestation.clientDataJSON),
attestationObject: arrayBufferToBase64(attestation.attestationObject),
},
}),
});
}
Authentication (Using a Passkey)
// Step 1: Fetch challenge and allowed credentials from server
async function fetchAuthenticationOptions(): Promise<PublicKeyCredentialRequestOptions> {
const response = await fetch("/api/auth/passkey/login/begin", {
method: "POST",
});
const options = await response.json();
options.challenge = base64ToArrayBuffer(options.challenge);
if (options.allowCredentials) {
options.allowCredentials = options.allowCredentials.map((c: { id: string; type: string }) => ({
...c,
id: base64ToArrayBuffer(c.id),
}));
}
return options;
}
export async function authenticateWithPasskey(): Promise<{ success: boolean; sessionToken: string }> {
const options = await fetchAuthenticationOptions();
const assertion = await navigator.credentials.get({
publicKey: options,
}) as PublicKeyCredential;
const assertionResponse = assertion.response as AuthenticatorAssertionResponse;
// Send assertion to server for verification
const result = await fetch("/api/auth/passkey/login/complete", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
id: assertion.id,
rawId: arrayBufferToBase64(assertion.rawId),
type: assertion.type,
response: {
clientDataJSON: arrayBufferToBase64(assertionResponse.clientDataJSON),
authenticatorData: arrayBufferToBase64(assertionResponse.authenticatorData),
signature: arrayBufferToBase64(assertionResponse.signature),
userHandle: assertionResponse.userHandle
? arrayBufferToBase64(assertionResponse.userHandle)
: null,
},
}),
});
return result.json();
}
// Helpers
function base64ToArrayBuffer(base64: string): ArrayBuffer {
const binary = atob(base64.replace(/-/g, "+").replace(/_/g, "/"));
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
return bytes.buffer;
}
function arrayBufferToBase64(buffer: ArrayBuffer): string {
return btoa(String.fromCharCode(...new Uint8Array(buffer)))
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=/g, "");
}
Server-Side Passkey Verification (Node.js with SimpleWebAuthn)
// server/passkey.ts
import {
generateRegistrationOptions,
verifyRegistrationResponse,
generateAuthenticationOptions,
verifyAuthenticationResponse,
} from "@simplewebauthn/server";
import type { RegistrationResponseJSON, AuthenticationResponseJSON } from "@simplewebauthn/types";
const RP_NAME = "My App";
const RP_ID = "myapp.com"; // Must match your domain
const ORIGIN = "https://myapp.com";
// --- Registration ---
export async function beginRegistration(userId: string, userName: string) {
const options = await generateRegistrationOptions({
rpName: RP_NAME,
rpID: RP_ID,
userID: new TextEncoder().encode(userId),
userName,
attestationType: "none", // "direct" for hardware key attestation
authenticatorSelection: {
residentKey: "required", // Required for passkeys
userVerification: "required", // Biometric or PIN required
authenticatorAttachment: "platform", // "cross-platform" for hardware keys
},
});
// Save challenge in your session/DB for verification
await saveChallenge(userId, options.challenge);
return options;
}
export async function completeRegistration(
userId: string,
response: RegistrationResponseJSON
) {
const expectedChallenge = await getChallenge(userId);
const verification = await verifyRegistrationResponse({
response,
expectedChallenge,
expectedOrigin: ORIGIN,
expectedRPID: RP_ID,
});
if (!verification.verified || !verification.registrationInfo) {
throw new Error("Registration verification failed.");
}
const { credential } = verification.registrationInfo;
// Store the credential in your database
await storeCredential(userId, {
id: credential.id,
publicKey: credential.publicKey,
counter: credential.counter,
transports: response.response.transports,
});
}
Magic Links
Magic links are time-limited, single-use tokens delivered via email. Simple, effective, and still widely used for lower-assurance flows.
// server/magicLink.ts
import { randomBytes } from "crypto";
import { hash, verify } from "argon2";
const TOKEN_EXPIRY_MINUTES = 15;
export async function sendMagicLink(email: string): Promise<void> {
const token = randomBytes(32).toString("hex");
const tokenHash = await hash(token); // Store only the hash
const expiry = new Date(Date.now() + TOKEN_EXPIRY_MINUTES * 60 * 1000);
await db.magicLinks.upsert({
where: { email },
create: { email, tokenHash, expiry },
update: { tokenHash, expiry },
});
const magicUrl = `https://myapp.com/auth/verify?token=${token}&email=${encodeURIComponent(email)}`;
await emailService.send({
to: email,
subject: "Your sign-in link",
html: `
<p>Click the link below to sign in. It expires in ${TOKEN_EXPIRY_MINUTES} minutes.</p>
<a href="${magicUrl}">Sign in to My App</a>
<p>If you didn't request this, you can safely ignore this email.</p>
`,
});
}
export async function verifyMagicLink(
email: string,
token: string
): Promise<{ userId: string; sessionToken: string }> {
const record = await db.magicLinks.findUnique({ where: { email } });
if (!record) throw new Error("No magic link found for this email.");
if (new Date() > record.expiry) throw new Error("Magic link has expired.");
const isValid = await verify(record.tokenHash, token);
if (!isValid) throw new Error("Invalid token.");
// Invalidate the token immediately (single-use)
await db.magicLinks.delete({ where: { email } });
// Find or create user
const user = await db.users.upsert({
where: { email },
create: { email, emailVerified: true },
update: { emailVerified: true },
});
const sessionToken = await createSession(user.id);
return { userId: user.id, sessionToken };
}
End-to-End Auth Flow: Putting It All Together
Here's a complete auth architecture for a modern SPA backed by a Node.js API:
┌─────────────────────────────────────────────────────────┐
│ Browser (SPA) │
│ │
│ 1. User clicks "Sign In" │
│ 2. Generate PKCE verifier + challenge │
│ 3. Redirect to Authorization Server with params │
└──────────────────────────┬──────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────┐
│ Authorization Server (IdP) │
│ │
│ 4. Authenticate user (passkey / password / SSO) │
│ 5. Issue authorization code │
│ 6. Redirect back to app with code + state │
└──────────────────────────┬──────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────┐
│ Browser (SPA) │
│ │
│ 7. Validate state parameter │
│ 8. Exchange code + verifier for tokens (via backend) │
└──────────────────────────┬──────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────┐
│ Your API (Backend) │
│ │
│ 9. Validate ID token signature, claims, nonce │
│ 10. Issue httpOnly session cookie or short-lived JWT │
│ 11. Store refresh token securely (encrypted, DB) │
└─────────────────────────────────────────────────────────┘
Token Storage Strategy
| Token Type | Where to Store | Why |
|---|---|---|
| Access Token | Memory (JS variable) | Short-lived; never persist to disk |
| Refresh Token | httpOnly cookie (server sets) | XSS-safe; CSRF protection via SameSite |
| ID Token | Memory or validated then discarded | Never store long-term |
| Session Cookie | httpOnly, Secure, SameSite=Strict | Defense-in-depth for server sessions |
// server/session.ts — Set tokens as secure cookies from your backend
import { FastifyReply } from "fastify";
export function setAuthCookies(reply: FastifyReply, tokens: TokenResponse): void {
// Refresh token in httpOnly cookie
if (tokens.refresh_token) {
reply.setCookie("refresh_token", tokens.refresh_token, {
httpOnly: true,
secure: true,
sameSite: "strict",
path: "/api/auth/refresh", // Scope to refresh endpoint only
maxAge: 60 * 60 * 24 * 30, // 30 days
});
}
// Short-lived access token in memory-friendly cookie (or return in body)
reply.setCookie("access_token", tokens.access_token, {
httpOnly: true,
secure: true,
sameSite: "strict",
path: "/",
maxAge: tokens.expires_in,
});
}
Best Practices
- Always use PKCE, even for confidential clients — it provides defense-in-depth against authorization code interception.
- Validate every JWT claim:
iss,aud,exp,iat, andnonce(when present). Use a well-maintained library likejose. - Implement token rotation with automatic revocation on reuse detection.
- Use short-lived access tokens (5–15 minutes) and long-lived refresh tokens stored in httpOnly cookies.
- Scope your cookies — set
pathto the most restrictive route needed (e.g.,/api/auth/refreshfor refresh tokens). - Support logout propagation — both local session termination and back-channel logout (if your IdP supports it).
- Rate limit auth endpoints — registration, login, token exchange, and magic link issuance.
- Log and monitor auth events — failed logins, token reuse, unusual patterns.
- Test with real browsers, not just unit tests — WebAuthn behavior varies across platforms.
- Prefer passkeys over TOTP for MFA when phishing resistance is a requirement.
Common Mistakes
❌ Skipping State Validation
The state parameter is your defense against CSRF in the authorization flow. Never skip it.
// ❌ Wrong — skipping state validation
const code = new URLSearchParams(window.location.search).get("code");
// immediately exchange code without checking state
// ✅ Correct
const returnedState = params.get("state");
const storedState = sessionStorage.getItem("oauth_state");
if (returnedState !== storedState) throw new Error("CSRF attack detected");
❌ Storing Tokens in localStorage
localStorage is accessible to any JavaScript on the page. One XSS vulnerability and all tokens are stolen.
// ❌ Wrong
localStorage.setItem("access_token", tokens.access_token);
// ✅ Correct — keep access tokens in memory, refresh tokens in httpOnly cookies
let accessToken = tokens.access_token; // in-memory, cleared on tab close
❌ Not Validating the ID Token
Accepting an ID token without verifying its signature and claims is a critical security flaw.
// ❌ Wrong — just decoding without verifying
const claims = JSON.parse(atob(idToken.split(".")[1]));
// ✅ Correct — verify signature and all claims
const claims = await validateIdToken(idToken, issuer, clientId, jwksUri, nonce);
❌ Using the Implicit Grant
The implicit grant was removed in OAuth 2.1 for good reason — tokens in URL fragments are logged in browser history and server logs.
// ❌ Wrong — implicit flow (response_type=token)
url.searchParams.set("response_type", "token");
// ✅ Correct — authorization code with PKCE
url.searchParams.set("response_type", "code");
url.searchParams.set("code_challenge", challenge);
url.searchParams.set("code_challenge_method", "S256");
❌ Not Rotating Refresh Tokens
A static refresh token that never changes is a persistent credential that attackers can harvest and abuse indefinitely.
// ❌ Wrong — reusing the same refresh token forever
const { access_token } = await refreshAccessToken(storedRefreshToken);
// ✅ Correct — always update to the new refresh token returned
const { access_token, refresh_token: newRefreshToken } = await refreshAccessToken(storedRefreshToken);
storeRefreshToken(newRefreshToken); // replace the old one
❌ Broad Redirect URI Matching
Allowing wildcard or prefix-matched redirect URIs enables open redirect attacks.
# ❌ Wrong — wildcard redirect URI
redirect_uri: https://myapp.com/*
# ✅ Correct — exact, pre-registered redirect URIs only
redirect_uri: https://myapp.com/auth/callback
🚀 Pro Tips
Use a JWKS cache with TTL — fetching the public key on every request is slow and brittle. Cache the JWKS response with a TTL of 1–24 hours, but always retry on a key ID (kid) miss.
import { createRemoteJWKSet } from "jose";
// jose's createRemoteJWKSet handles caching automatically
const JWKS = createRemoteJWKSet(new URL(jwksUri), {
cacheMaxAge: 10 * 60 * 1000, // 10 minutes
});
Implement silent token refresh using a hidden iframe or background fetch before the access token expires, giving users a seamless experience without re-authentication prompts.
Use prompt=none in OIDC to check if a user already has an active session at the IdP without showing any UI — great for auto-login on return visits.
url.searchParams.set("prompt", "none"); // Fails silently if no session exists
Adopt Demonstrating Proof of Possession (DPoP) — an OAuth 2.1 extension that binds access tokens to the client's key pair, preventing token theft even if an attacker intercepts the token.
For multi-tenant SaaS, use a single authorization server with organization-scoped claims rather than separate auth stacks per tenant. Pass organization_id as a custom claim in the ID token and validate it in your API.
Instrument your auth layer with distributed tracing — attach a correlation_id to every auth event so you can trace the full flow from authorization request to session creation across services.
Consider Pushed Authorization Requests (PAR) for high-security applications. PAR sends the authorization parameters directly to the authorization server before the redirect, preventing parameter tampering in the browser.
📌 Key Takeaways
- OAuth 2.1 mandates PKCE, removes the implicit grant, and requires exact redirect URI matching and refresh token rotation — adopt these now even if you're still on 2.0.
- OpenID Connect adds a standardized identity layer on top of OAuth. Always validate the ID token's signature and claims server-side.
- Passkeys (WebAuthn) are phishing-resistant, hardware-backed, and now mainstream. If you're building high-assurance flows, passkeys should be your default.
- Never store tokens in localStorage. Access tokens belong in memory; refresh tokens belong in httpOnly, Secure, SameSite=Strict cookies.
- State and nonce are not optional. They are your defenses against CSRF and token replay attacks.
- Short-lived access tokens + rotating refresh tokens give you the best balance of usability and security.
- Proper logout requires both local session clearing and RP-initiated logout at the IdP.
- Use established libraries —
josefor JWT operations,@simplewebauthn/serverfor WebAuthn — rather than rolling your own cryptography.
Conclusion
Authentication in 2026 is no longer just about checking a username and password. It's a layered system of protocols, cryptographic proofs, and security properties that must be implemented with precision.
OAuth 2.1 gives us a cleaner, safer authorization framework. OpenID Connect layers a robust identity protocol on top. Passkeys eliminate the password problem entirely. Together, they form the backbone of modern, secure identity and access management.
The path forward is clear: adopt PKCE universally, validate tokens rigorously, store credentials carefully, and offer passkeys as a first-class login option. Your users — and your security team — will thank you.
Auth is one of those areas where getting it right matters enormously. Take the time to understand the flows, test the edge cases, and stay up to date as the standards continue to evolve.
Have questions or corrections? Open an issue on GitHub or reach out on X (formerly Twitter). Security feedback is always welcome and handled responsibly.