Passwords have been the weakest link in web security for decades. In 2026, we are finally at the tipping point. Passkeys are supported by every major browser and platform, OAuth 2.1 has consolidated the fragmented authorization landscape, and auth providers have made implementation almost trivial. Here is where things stand and how to build auth the right way today.
The State of Auth in 2026
The numbers tell the story. Google reports over 2 billion accounts using passkeys. Apple and Microsoft have followed suit, making passkey creation the default flow for new accounts. The FIDO Alliance's push has paid off — phishing-resistant authentication is no longer theoretical.
Meanwhile, OAuth 2.1 (RFC 9728) has replaced the tangled mess of OAuth 2.0 extensions. The implicit grant is dead. PKCE is mandatory. Refresh token rotation is the default. The spec is smaller, clearer, and harder to implement wrong.
Passkeys Explained
A passkey is a WebAuthn credential stored on your device (or synced across devices via your platform's cloud keychain). When you authenticate, your device performs a cryptographic challenge-response with the server. No shared secret ever crosses the network.
The flow works like this:
- Registration: The server sends a challenge. Your device generates a key pair, stores the private key, and sends back the public key.
- Authentication: The server sends a new challenge. Your device signs it with the private key. The server verifies with the stored public key.
No password to steal. No phishing possible (the credential is bound to the origin). No password reuse across sites.
Implementing Passkeys with SimpleWebAuthn
The @simplewebauthn/server and @simplewebauthn/browser libraries make this straightforward.
Server-side registration (Node.js):
import {
generateRegistrationOptions,
verifyRegistrationResponse,
} from '@simplewebauthn/server';
const rpName = 'My App';
const rpID = 'myapp.com';
const origin = 'https://myapp.com';
// Generate options for the client
const options = await generateRegistrationOptions({
rpName,
rpID,
userID: user.id,
userName: user.email,
authenticatorSelection: {
residentKey: 'preferred',
userVerification: 'preferred',
},
});
// Store the challenge for verification
await storeChallenge(user.id, options.challenge);Client-side registration:
import { startRegistration } from '@simplewebauthn/browser';
const response = await startRegistration(options);
// Send response to server for verification
await fetch('/api/auth/register-passkey', {
method: 'POST',
body: JSON.stringify(response),
});Server-side verification:
const verification = await verifyRegistrationResponse({
response: registrationResponse,
expectedChallenge: storedChallenge,
expectedOrigin: origin,
expectedRPID: rpID,
});
if (verification.verified) {
// Store the credential for future authentication
await storeCredential(user.id, verification.registrationInfo);
}Authentication follows the same pattern with generateAuthenticationOptions, startAuthentication, and verifyAuthenticationResponse.
OAuth 2.1: What Changed
OAuth 2.1 is not a new protocol. It consolidates OAuth 2.0 and its best-practice extensions into a single spec. Here is what matters:
Removed:
- Implicit grant (was vulnerable to token leakage)
- Resource Owner Password Credentials grant (was just passwords with extra steps)
Now mandatory:
- PKCE for all authorization code flows (not just public clients)
- Exact redirect URI matching (no wildcards)
- Refresh token rotation or sender-constrained tokens
Practical impact: If you were already following best practices, you change nothing. If you were using the implicit grant in your SPA, migrate to authorization code + PKCE now.
// Authorization code + PKCE flow (simplified)
import crypto from 'crypto';
// Generate PKCE verifier and challenge
const verifier = crypto.randomBytes(32).toString('base64url');
const challenge = crypto
.createHash('sha256')
.update(verifier)
.digest('base64url');
// Include in authorization request
const authUrl = new URL('https://auth.provider.com/authorize');
authUrl.searchParams.set('response_type', 'code');
authUrl.searchParams.set('client_id', CLIENT_ID);
authUrl.searchParams.set('redirect_uri', REDIRECT_URI);
authUrl.searchParams.set('code_challenge', challenge);
authUrl.searchParams.set('code_challenge_method', 'S256');
authUrl.searchParams.set('scope', 'openid profile email');Session Management Best Practices
Authentication is only half the problem. Session management is where most apps get sloppy.
Use short-lived access tokens with refresh tokens. Access tokens should expire in 15 minutes or less. Refresh tokens should rotate on every use — if a refresh token is used twice, revoke the entire session.
Store session data server-side. JWTs are fine for stateless verification, but if you need revocation (and you do), you need a server-side session store. Redis works well. A database works fine for lower traffic.
Implement device sessions. Users expect to see and manage their active sessions. Store device metadata (browser, OS, location) with each session. Let users revoke individual sessions.
// Session creation with device tracking
async function createSession(userId: string, request: Request) {
const ua = request.headers.get('user-agent') || 'unknown';
const ip = request.headers.get('x-forwarded-for') || 'unknown';
const session = {
id: crypto.randomUUID(),
userId,
deviceInfo: parseUserAgent(ua),
ipAddress: ip,
createdAt: new Date(),
expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // 30 days
lastActiveAt: new Date(),
};
await redis.set(`session:${session.id}`, JSON.stringify(session), {
EX: 30 * 24 * 60 * 60,
});
return session;
}Comparing Auth Providers
Choosing an auth provider depends on your needs. Here is how the major options compare in 2026:
NextAuth.js (Auth.js v5): Open source, self-hosted, full control. Best for developers who want to own their auth stack. Supports passkeys via the WebAuthn provider. Free, but you manage the infrastructure.
Clerk: Managed auth with excellent DX. Pre-built UI components, user management dashboard, organization support. Best for teams that want auth solved completely. Generous free tier, then per-user pricing.
Auth0 (Okta): Enterprise-grade, most feature-rich. Best for apps with complex requirements (SAML, SCIM, custom MFA policies). More expensive, steeper learning curve.
Firebase Auth: Simple, cheap, well-integrated with the Firebase ecosystem. Passkey support landed in late 2025. Best for projects already using Firebase services. Free up to 50k MAUs.
For a solo developer or small team building a Next.js app, Clerk or Auth.js v5 are the pragmatic choices. Clerk if you want zero auth headaches. Auth.js if you want full control and zero vendor lock-in.
The Migration Path
If your app still uses passwords, here is the practical migration:
- Add passkey support alongside passwords. Let users register passkeys from their account settings.
- Prompt passkey creation after password login. A non-intrusive banner works well.
- Make passkeys the default for new accounts. Still offer email magic links as a fallback.
- After 6-12 months, deprecate passwords. Require existing password users to add a passkey.
Do not rush the deprecation. Users need time. But set a date and stick to it.
What Comes Next
WebAuthn Level 3 is in draft, bringing conditional UI improvements and better cross-device authentication. The identity layer of the web is converging on FIDO2/passkeys + OpenID Connect, and that is a good thing.
The days of bcrypt, password reset emails, and credential stuffing are numbered. Build your next project with passkeys from day one. Your users — and your security team — will thank you.