Authentication in 2026: Passkeys, OAuth 2.1, and Beyond
Back to Blog

Authentication in 2026: Passkeys, OAuth 2.1, and Beyond

March 21, 20263 min read22 views

Authentication is evolving. Passkeys promise a passwordless future with better security than passwords ever offered. OAuth 2.1 cleans up years of security debt and confusion in the spec.

Passkeys: The End of Passwords?

Passkeys use public-key cryptography tied to device biometrics or PINs:

// Registration flow
async function registerPasskey(userId: string) {
  // Generate challenge on server
  const options = await generateRegistrationOptions({
    rpName: 'My App',
    rpID: 'myapp.com',
    userID: userId,
    userName: userEmail,
    authenticatorSelection: {
      authenticatorAttachment: 'platform',
      requireResidentKey: true,
      userVerification: 'required'
    }
  })
  
  // Client creates credential
  const credential = await navigator.credentials.create({
    publicKey: options
  })
  
  // Verify and store on server
  await verifyRegistrationResponse({
    response: credential,
    expectedChallenge: options.challenge,
    expectedOrigin: 'https://myapp.com',
    expectedRPID: 'myapp.com'
  })
}

// Authentication flow
async function authenticateWithPasskey() {
  const options = await generateAuthenticationOptions({
    rpID: 'myapp.com',
    userVerification: 'required'
  })
  
  const credential = await navigator.credentials.get({
    publicKey: options
  })
  
  // Verify signature on server
  const verified = await verifyAuthenticationResponse({
    response: credential,
    expectedChallenge: options.challenge,
    expectedOrigin: 'https://myapp.com',
    expectedRPID: 'myapp.com',
    authenticator: storedAuthenticator
  })
  
  if (verified) {
    return createSession(userId)
  }
}

OAuth 2.1: What Changed

OAuth 2.1 consolidates best practices:

  • PKCE required for all clients (not just public)
  • Implicit grant removed
  • Resource Owner Password grant removed
  • Refresh token rotation recommended
// Modern OAuth 2.1 with PKCE
import { generateCodeVerifier, generateCodeChallenge } from 'oauth-utils'

async function initiateOAuth() {
  const codeVerifier = generateCodeVerifier()
  const codeChallenge = await generateCodeChallenge(codeVerifier)
  
  // Store verifier for later
  sessionStorage.setItem('oauth_verifier', codeVerifier)
  
  // Redirect to authorization server
  const params = new URLSearchParams({
    client_id: CLIENT_ID,
    redirect_uri: REDIRECT_URI,
    response_type: 'code',
    scope: 'openid profile email',
    code_challenge: codeChallenge,
    code_challenge_method: 'S256',
    state: generateState()
  })
  
  window.location.href = `${AUTH_SERVER}/authorize?${params}`
}

async function handleCallback(code: string) {
  const codeVerifier = sessionStorage.getItem('oauth_verifier')
  
  const response = await fetch(`${AUTH_SERVER}/token`, {
    method: 'POST',
    body: new URLSearchParams({
      grant_type: 'authorization_code',
      code,
      redirect_uri: REDIRECT_URI,
      client_id: CLIENT_ID,
      code_verifier: codeVerifier  // PKCE verification
    })
  })
  
  return response.json()
}

Session Management: JWTs vs Server Sessions

JWTs (Stateless):
✅ No database lookup per request
✅ Works across services
❌ Can't invalidate until expiry
❌ Token bloat in cookies

Server Sessions (Stateful):
✅ Instant revocation
✅ Smaller cookie size
❌ Database lookup per request
❌ Scaling complexity

Hybrid (Recommended):
- Short-lived access tokens (15 min)
- Server-side refresh token validation
- Best of both worlds

Implementing with NextAuth.js v5

// auth.ts
import NextAuth from 'next-auth'
import Passkey from 'next-auth/providers/passkey'
import Google from 'next-auth/providers/google'

export const { handlers, signIn, signOut, auth } = NextAuth({
  providers: [
    Passkey({
      name: 'Passkey'
    }),
    Google({
      clientId: process.env.GOOGLE_CLIENT_ID,
      clientSecret: process.env.GOOGLE_CLIENT_SECRET
    })
  ],
  callbacks: {
    session({ session, token }) {
      session.user.id = token.sub
      return session
    }
  },
  experimental: {
    enableWebAuthn: true
  }
})

// middleware.ts
import { auth } from './auth'

export default auth((req) => {
  if (!req.auth && req.nextUrl.pathname.startsWith('/dashboard')) {
    return Response.redirect(new URL('/login', req.url))
  }
})

Security Checklist

□ HTTPS everywhere (including dev)
□ Secure, HttpOnly, SameSite cookies
□ CSRF protection
□ Rate limiting on auth endpoints
□ Account lockout after failed attempts
□ Secure password hashing (Argon2)
□ Session invalidation on password change
□ Audit logging for auth events
□ 2FA/MFA support
□ Passkey support for passwordless

Conclusion

Modern authentication balances security and UX. Support passkeys for passwordless where possible, implement OAuth 2.1 correctly with PKCE, and use established libraries like NextAuth. The patterns here provide a secure foundation—but security is ongoing, not one-time.

Share this article