Authentication Patterns in Modern Web Apps: JWT, Sessions, and Passkeys

Authentication is the security boundary of every web application. The choice between JWT, session-based auth, and passkeys affects your security posture, user experience, and operational complexity. Here is a practical comparison with implementation guidance for each approach.
Session-Based Authentication: The Server-Managed Approach
Session auth stores a session identifier in an HTTP-only cookie and keeps session data on the server — typically in Redis or PostgreSQL. It is the most straightforward model for traditional server-rendered applications.
// Next.js App Router session-based auth with Iron Session
import { getIronSession } from 'iron-session'
import { cookies } from 'next/headers'
interface SessionData {
userId: string
role: 'user' | 'admin'
}
export async function getSession() {
const cookieStore = await cookies()
const session = await getIronSession<SessionData>(cookieStore, {
password: process.env.SESSION_SECRET!,
cookieName: 'session',
})
return session
}
export async function login(email: string, password: string) {
const user = await authenticateUser(email, password)
const session = await getSession()
session.userId = user.id
session.role = user.role
await session.save()
}
Session auth gives you the ability to revoke sessions instantly — delete the session from the database and the user is logged out. The trade-off is server-side storage costs and the need for a shared session store in distributed deployments.
JWT Authentication: Stateless but Manageable
JSON Web Tokens encode user claims in a signed token. No server-side storage is needed — the token itself is the source of truth. This makes JWTs ideal for APIs and microservice architectures.
import { SignJWT, jwtVerify } from 'jose'
const secret = new TextEncoder().encode(process.env.JWT_SECRET)
export async function createToken(payload: { userId: string; role: string }) {
return new SignJWT(payload)
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setExpirationTime('15m')
.sign(secret)
}
export async function verifyToken(token: string) {
try {
const { payload } = await jwtVerify(token, secret)
return payload as { userId: string; role: string }
} catch {
return null
}
}
Keep JWT expiration short — 15 minutes for access tokens. Use refresh tokens with longer expiry (7 days) stored in an HTTP-only cookie. This limits the damage window if a token is compromised while maintaining a seamless user experience.
Passkeys: The Passwordless Future
Passkeys use WebAuthn to authenticate users with biometrics or device PIN. They replace passwords entirely and are phishing-resistant by design. Browser support is now universal across major platforms.
import { generateRegistrationOptions, verifyRegistrationResponse } from '@simplewebauthn/server'
export async function startPasskeyRegistration(user: { id: string; name: string }) {
const options = await generateRegistrationOptions({
rpName: 'Your App',
rpID: process.env.RP_ID!,
userName: user.name,
userID: Buffer.from(user.id),
})
// Store challenge temporarily for verification
await redis.set(`challenge:${user.id}`, options.challenge, { ex: 120 })
return options
}
The authentication flow is faster than any password-based system — users authenticate in under a second with their fingerprint or face. Adoption is accelerating as major platforms (Apple, Google, Microsoft) push passkeys as the primary authentication method.
Comparison Matrix
Criteria | Sessions | JWT | Passkeys
-------------------|----------------|------------------|------------------
Server storage | Required | None | Public key only
Revocation | Instant | Token expiry | N/A
Phishing resistant | No | No | Yes
User experience | Cookie-based | Token header | Biometric/PIN
Cross-domain | Difficult | Easy | Requires RP ID
Implementation | Simple | Moderate | Complex
Hybrid Strategy for Production
Most production applications benefit from a hybrid approach. Use session-based auth for the main web application — the server has control, revocation is instant, and cookie handling is well-understood. Use JWT tokens for API access from mobile apps or third-party integrations where sending cookies is impractical. Layer passkeys on top as an authentication method that replaces password-based login entirely.
// Hybrid approach in middleware
export async function authenticate(request: NextRequest) {
// Check session cookie first
const sessionCookie = request.cookies.get('session')
if (sessionCookie) {
const user = await verifySessionCookie(sessionCookie.value)
if (user) return user
}
// Fall back to JWT Bearer token
const authHeader = request.headers.get('Authorization')
if (authHeader?.startsWith('Bearer ')) {
const user = await verifyToken(authHeader.slice(7))
if (user) return user
}
return null
}
Authentication decisions affect every layer of your application. Choosing the right pattern depends on your architecture, security requirements, and user base. At SoniNow, we design authentication systems that balance security with usability — from session management to passkey enrollment.
Need authentication architecture advice? Contact SoniNow and let us help you build a secure, user-friendly auth system for your web application.
Related Insights

API Rate Limiting Strategies: Token Bucket, Leaky Bucket, and Sliding Window
A guide to implementing API rate limiting including token bucket, leaky bucket, sliding window, and distributed rate limiting with Redis for production APIs.

API Security Best Practices: Authentication, Rate Limiting, and Input Validation
Best practices for securing APIs including API key management, OAuth token validation, rate limiting, input sanitization, CORS configuration, and request signing.

Authentication Patterns in Modern Web Apps: JWT, OAuth, and Session Management
A guide to authentication patterns for web applications including JWT implementation, OAuth 2.0 flows, refresh tokens, session management, and secure storage.