- Published on
OAuth 2.0 & Authentication Complete Guide 2025: JWT, Sessions, SSO, OIDC, Passkey
- Authors

- Name
- Youngju Kim
- @fjvbn20031
- Introduction
- 1. Authentication vs Authorization
- 2. Session-based Authentication
- 3. Token-based Authentication
- 4. JWT Deep Dive
- 5. OAuth 2.0 Flows
- 6. OpenID Connect (OIDC)
- 7. SSO (Single Sign-On)
- 8. Social Login Implementation
- 9. Passkey / WebAuthn / FIDO2
- 10. Multi-Factor Authentication (MFA)
- 11. Security Best Practices
- 12. Auth Libraries Comparison
- 13. Session vs JWT Comparison Table
- 14. Quiz
- References
Introduction
In web applications, "Authentication" and "Authorization" are the cornerstones of security. From user login to API access control and inter-microservice communication, you cannot build a secure system without a proper authentication scheme.
As of 2025, the authentication ecosystem is evolving rapidly. From traditional session-based authentication to JWT token-based authentication, OAuth 2.0, OIDC, and Passkey, the choices are more diverse than ever.
This guide covers everything about web authentication, from the fundamental difference between Authentication vs Authorization, through JWT deep dive, all OAuth 2.0 flows, SSO, Passkey, to security best practices.
1. Authentication vs Authorization
1.1 Core Difference
Authentication - "Who are you?"
- The process of verifying a user's identity
- The login process itself
- Examples: username/password, biometrics, OTP
Authorization - "What are you allowed to do?"
- The process of checking an authenticated user's permissions
- Resource access control
- Examples: only admins can delete, users can only view their own data
1.2 Authentication/Authorization Flow
1. User sends login request (Authentication)
POST /auth/login
Body: { "email": "user@example.com", "password": "..." }
2. Server verifies credentials (Authentication)
- Look up user in DB
- Compare password hash
- Issue token/session on success
3. Include token/session with API requests
GET /admin/users
Authorization: Bearer eyJhbGci...
4. Server checks permissions (Authorization)
- Extract role from token
- Verify access to endpoint
- Has permission -> 200 OK
- No permission -> 403 Forbidden
1.3 HTTP Status Codes
401 Unauthorized = Authentication failure
- "I don't know who you are. Please log in."
- Missing token, expired token, invalid credentials
403 Forbidden = Authorization failure
- "I know who you are, but you don't have access to this resource."
- Regular user calling admin-only API
2. Session-based Authentication
2.1 How It Works
1. Client: POST /login (email, password)
2. Server: Validates credentials
3. Server: Creates session (stored in server memory/Redis)
Session ID: "sess_abc123"
Data: { userId: 42, role: "admin", createdAt: "..." }
4. Server: Returns session ID via Set-Cookie header
Set-Cookie: session_id=sess_abc123; HttpOnly; Secure; SameSite=Strict
5. Client: Automatically sends cookie with every subsequent request
Cookie: session_id=sess_abc123
6. Server: Looks up user info using session ID
2.2 Express.js Session Implementation
const express = require('express');
const session = require('express-session');
const RedisStore = require('connect-redis').default;
const redis = require('redis');
const app = express();
const redisClient = redis.createClient({ url: 'redis://localhost:6379' });
app.use(session({
store: new RedisStore({ client: redisClient }),
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
cookie: {
secure: true, // Only sent over HTTPS
httpOnly: true, // Block JavaScript access (XSS prevention)
sameSite: 'strict', // CSRF prevention
maxAge: 24 * 60 * 60 * 1000 // 24 hours
}
}));
// Login
app.post('/login', async (req, res) => {
const { email, password } = req.body;
const user = await db.findUserByEmail(email);
if (!user || !await bcrypt.compare(password, user.passwordHash)) {
return res.status(401).json({ error: 'Invalid credentials' });
}
req.session.userId = user.id;
req.session.role = user.role;
res.json({ message: 'Login successful' });
});
// Authentication middleware
function requireAuth(req, res, next) {
if (!req.session.userId) {
return res.status(401).json({ error: 'Authentication required' });
}
next();
}
// Authorization middleware
function requireRole(role) {
return (req, res, next) => {
if (req.session.role !== role) {
return res.status(403).json({ error: 'Insufficient permissions' });
}
next();
};
}
// Logout
app.post('/logout', (req, res) => {
req.session.destroy((err) => {
res.clearCookie('connect.sid');
res.json({ message: 'Logout successful' });
});
});
2.3 Session Store Comparison
| Store | Pros | Cons | Best For |
|---|---|---|---|
| Memory | Fastest | Lost on restart, not shared | Development |
| Redis | Fast, shared, TTL support | Extra infrastructure | Production (recommended) |
| DB (PostgreSQL) | Persistent, auditable | Slow | Audit requirements |
| File | Simple implementation | Slow, not shared | Small apps |
3. Token-based Authentication
3.1 JWT Structure
JWT = Header.Payload.Signature (Base64URL encoded, separated by dots)
Header:
{
"alg": "RS256", // Signing algorithm
"typ": "JWT", // Token type
"kid": "key-id-1" // Key identifier (for Key Rotation)
}
Payload (Claims):
{
"iss": "https://auth.example.com", // Issuer
"sub": "user-123", // Subject
"aud": "https://api.example.com", // Audience
"exp": 1711356600, // Expiration
"iat": 1711353000, // Issued At
"nbf": 1711353000, // Not Before
"jti": "unique-token-id", // JWT ID
"email": "user@example.com", // Custom claim
"roles": ["admin", "user"] // Custom claim
}
Signature:
RS256(
base64url(header) + "." + base64url(payload),
privateKey
)
3.2 Signing Algorithm Comparison
| Algorithm | Type | Key | Use Cases |
|---|---|---|---|
| HS256 | Symmetric | Single secret key | Single server, internal services |
| RS256 | Asymmetric | Public/private key pair | Microservices, external verification |
| ES256 | Asymmetric (ECDSA) | Shorter key length | Mobile, IoT (performance critical) |
// HS256 - Sign and verify with the same key
const jwt = require('jsonwebtoken');
const SECRET = process.env.JWT_SECRET;
// Sign
const token = jwt.sign({ sub: 'user-123' }, SECRET, { algorithm: 'HS256' });
// Verify (same key required)
const decoded = jwt.verify(token, SECRET);
// RS256 - Sign with private key, verify with public key
const fs = require('fs');
const privateKey = fs.readFileSync('private.pem');
const publicKey = fs.readFileSync('public.pem');
// Sign (Auth server)
const token = jwt.sign(
{ sub: 'user-123', roles: ['admin'] },
privateKey,
{ algorithm: 'RS256', expiresIn: '15m' }
);
// Verify (API server - only needs public key)
const decoded = jwt.verify(token, publicKey);
3.3 JWT Verification Checklist
1. Signature Verification
- Verify signed with the correct algorithm
- Prevent alg: "none" attacks (always specify algorithm)
2. Expiration Check (exp)
- Ensure current time is before exp
- Allow slight leeway for clock skew
3. Issuer Check (iss)
- Verify from a trusted issuer
4. Audience Check (aud)
- Verify this token is meant for my service
5. Not Before Check (nbf)
- Ensure current time is after nbf
6. Additional Security Checks
- Check if token is on blacklist
- Check if user is deactivated
4. JWT Deep Dive
4.1 Access Token and Refresh Token
Access Token:
- Short validity (15 minutes - 1 hour)
- Included in Authorization header for API requests
- Limited damage window if stolen
- Not stored on server (Stateless)
Refresh Token:
- Long validity (7 days - 30 days)
- Used only to renew Access Tokens
- Stored in server DB/Redis (Stateful)
- Stored in HttpOnly cookie (XSS prevention)
4.2 Token Renewal Flow
1. Initial Login
POST /auth/login -> Access Token + Refresh Token issued
2. API Request
GET /api/data
Authorization: Bearer [access_token]
-> 200 OK (success)
3. Access Token Expired
GET /api/data
Authorization: Bearer [expired_access_token]
-> 401 Unauthorized
4. Token Renewal
POST /auth/refresh
Cookie: refresh_token=...
-> New Access Token + New Refresh Token issued
5. Refresh Token Expired
POST /auth/refresh
-> 401 Unauthorized -> Re-login required
4.3 Refresh Token Rotation
// Refresh Token Rotation implementation
async function refreshTokens(refreshToken) {
// 1. Look up Refresh Token in DB
const storedToken = await db.findRefreshToken(refreshToken);
if (!storedToken) {
// Already used Refresh Token -> invalidate all tokens (theft detected)
await db.revokeAllTokensForUser(storedToken.userId);
throw new Error('Refresh Token reuse detected');
}
// 2. Validate
if (storedToken.expiresAt < Date.now()) {
throw new Error('Refresh Token expired');
}
// 3. Invalidate previous Refresh Token
await db.revokeRefreshToken(refreshToken);
// 4. Issue new token pair
const newAccessToken = jwt.sign(
{ sub: storedToken.userId, roles: storedToken.roles },
privateKey,
{ algorithm: 'RS256', expiresIn: '15m' }
);
const newRefreshToken = crypto.randomUUID();
await db.saveRefreshToken({
token: newRefreshToken,
userId: storedToken.userId,
expiresAt: Date.now() + 7 * 24 * 60 * 60 * 1000
});
return { accessToken: newAccessToken, refreshToken: newRefreshToken };
}
4.4 Token Blacklist
// Redis-based blacklist
const redis = require('redis');
const client = redis.createClient();
// Add token to blacklist (on logout)
async function blacklistToken(token) {
const decoded = jwt.decode(token);
const ttl = decoded.exp - Math.floor(Date.now() / 1000);
if (ttl > 0) {
await client.set(`blacklist:${decoded.jti}`, '1', { EX: ttl });
}
}
// Check blacklist during verification
async function verifyToken(token) {
const decoded = jwt.verify(token, publicKey);
const isBlacklisted = await client.get(`blacklist:${decoded.jti}`);
if (isBlacklisted) {
throw new Error('Token has been revoked');
}
return decoded;
}
4.5 JWT Best Practices
DO:
- Keep Access Token validity short (15 minutes)
- Use RS256 or ES256 (asymmetric keys)
- Use jti claim for unique token identification
- Manage Refresh Tokens server-side
- Never put sensitive data in Payload
DON'T:
- Store JWT in localStorage (vulnerable to XSS)
- Use HS256 across microservices
- Include passwords/card numbers in tokens
- Allow alg: "none"
- Issue tokens without expiration
5. OAuth 2.0 Flows
5.1 OAuth 2.0 Roles
Resource Owner:
- The end user (e.g., Google account holder)
Client:
- The application requesting resource access (e.g., your web app)
Authorization Server:
- The server issuing tokens (e.g., Google OAuth server)
Resource Server:
- The server hosting protected resources (e.g., Google API)
5.2 Authorization Code Flow (+ PKCE)
The most recommended flow. Used in web apps, SPAs, and mobile apps.
PKCE (Proof Key for Code Exchange) is mandatory for SPAs/mobile.
1. Client generates PKCE parameters
code_verifier = random(43-128 chars)
code_challenge = BASE64URL(SHA256(code_verifier))
2. Authorization request
GET /authorize?
response_type=code&
client_id=my-app&
redirect_uri=https://myapp.com/callback&
scope=openid profile email&
state=random-csrf-token&
code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM&
code_challenge_method=S256
3. User logs in + consents
4. Authorization Server returns Authorization Code
302 Redirect: https://myapp.com/callback?code=AUTH_CODE&state=random-csrf-token
5. Exchange Authorization Code for Access Token
POST /token
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code&
code=AUTH_CODE&
redirect_uri=https://myapp.com/callback&
client_id=my-app&
code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk
6. Token response
{
"access_token": "eyJhbGci...",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "dGhpcyBpcyBhIH...",
"id_token": "eyJhbGci...",
"scope": "openid profile email"
}
5.3 Client Credentials Flow
Server-to-server communication (Machine-to-Machine). No user involvement.
POST /token
Content-Type: application/x-www-form-urlencoded
grant_type=client_credentials&
client_id=service-a&
client_secret=secret-value&
scope=read:data write:data
Response:
{
"access_token": "eyJhbGci...",
"token_type": "Bearer",
"expires_in": 3600,
"scope": "read:data write:data"
}
Use cases:
- Microservice-to-microservice API calls
- Batch processing systems
- API access in CI/CD pipelines
5.4 Device Code Flow
For input-constrained devices (Smart TVs, CLI tools, IoT)
1. Device requests a code
POST /device/code
client_id=tv-app
Response:
{
"device_code": "GmRhmhcxhwAzkoEqiMEg_DnyEysNkuNhszIySk9eS",
"user_code": "WDJB-MJHT",
"verification_uri": "https://auth.example.com/device",
"interval": 5,
"expires_in": 1800
}
2. Display to user
"Go to https://auth.example.com/device and enter code WDJB-MJHT"
3. User enters code on another device (phone/PC) + logs in
4. Device polls
POST /token
grant_type=urn:ietf:params:oauth:grant-type:device_code&
device_code=GmRhmhcxhwAzkoEqiMEg_DnyEysNkuNhszIySk9eS&
client_id=tv-app
(User not yet authenticated -> "authorization_pending")
(User authenticated -> Access Token returned)
5.5 Implicit Flow (Deprecated)
No longer recommended! Use Authorization Code Flow with PKCE instead.
Why deprecated:
1. Access Token exposed in URL Fragment (#access_token=...)
2. Token recorded in browser history
3. Cannot issue Refresh Tokens
4. Vulnerable to Token Substitution Attacks
Alternative: Authorization Code + PKCE
- Safe to use even in SPAs
- Supports Refresh Tokens
- code_verifier prevents code interception
6. OpenID Connect (OIDC)
6.1 What is OIDC
An authentication layer built on top of OAuth 2.0.
OAuth 2.0 = Handles Authorization only
OIDC = Adds Authentication
OAuth 2.0: "Can this app access your Google Drive?"
OIDC: "Verify that this user is actually alice@gmail.com"
Key additions:
1. ID Token (user information in JWT format)
2. UserInfo endpoint
3. Standardized claims (name, email, picture, etc.)
4. Discovery document (/.well-known/openid-configuration)
6.2 ID Token
{
"iss": "https://accounts.google.com",
"sub": "110169484474386276334",
"aud": "my-app-client-id",
"exp": 1711356600,
"iat": 1711353000,
"nonce": "n-0S6_WzA2Mj",
"email": "alice@gmail.com",
"email_verified": true,
"name": "Alice Kim",
"picture": "https://lh3.googleusercontent.com/a/..."
}
6.3 Discovery Document
GET https://accounts.google.com/.well-known/openid-configuration
Response (key fields):
{
"issuer": "https://accounts.google.com",
"authorization_endpoint": "https://accounts.google.com/o/oauth2/v2/auth",
"token_endpoint": "https://oauth2.googleapis.com/token",
"userinfo_endpoint": "https://openidconnect.googleapis.com/v1/userinfo",
"jwks_uri": "https://www.googleapis.com/oauth2/v3/certs",
"scopes_supported": ["openid", "email", "profile"],
"response_types_supported": ["code", "token", "id_token"],
"subject_types_supported": ["public"],
"id_token_signing_alg_values_supported": ["RS256"]
}
7. SSO (Single Sign-On)
7.1 SSO Concept
A mechanism to access multiple services with a single login.
Example:
Logging into Google gives you access to Gmail, Drive, YouTube, Calendar.
Pros:
- Improved user experience (single login)
- Reduced password fatigue
- Centralized user management
- Consistent security policies
Cons:
- Single point of failure (IdP outage affects all services)
- Implementation complexity
- Dependency on IdP
7.2 SAML 2.0 vs OIDC
| Feature | SAML 2.0 | OIDC |
|---|---|---|
| Data Format | XML | JSON/JWT |
| Transport | HTTP Redirect/POST | HTTP Redirect |
| Token | Assertion (XML) | ID Token (JWT) |
| Primary Use | Enterprise SSO | Web/Mobile apps |
| Complexity | High | Low |
| Mobile Support | Limited | Excellent |
| Standard Year | 2005 | 2014 |
SAML 2.0 Flow:
1. User accesses SP (Service Provider)
2. SP generates SAML Request -> redirects to IdP
3. User authenticates at IdP
4. IdP generates SAML Assertion (XML) -> POSTs to SP
5. SP validates Assertion -> creates session
OIDC Flow:
1. User accesses app
2. App redirects Authorization request to IdP
3. User authenticates at IdP
4. IdP returns Authorization Code
5. App exchanges Code for ID Token + Access Token
8. Social Login Implementation
8.1 Google Login (Next.js + NextAuth.js)
// app/api/auth/[...nextauth]/route.js
import NextAuth from 'next-auth';
import GoogleProvider from 'next-auth/providers/google';
import GitHubProvider from 'next-auth/providers/github';
const handler = NextAuth({
providers: [
GoogleProvider({
clientId: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
}),
GitHubProvider({
clientId: process.env.GITHUB_CLIENT_ID,
clientSecret: process.env.GITHUB_CLIENT_SECRET,
}),
],
callbacks: {
async jwt({ token, account, profile }) {
if (account) {
token.accessToken = account.access_token;
token.provider = account.provider;
}
return token;
},
async session({ session, token }) {
session.accessToken = token.accessToken;
session.provider = token.provider;
return session;
},
},
pages: {
signIn: '/auth/signin',
error: '/auth/error',
},
});
export { handler as GET, handler as POST };
8.2 GitHub Login (Express.js + Passport.js)
const passport = require('passport');
const GitHubStrategy = require('passport-github2').Strategy;
passport.use(new GitHubStrategy({
clientID: process.env.GITHUB_CLIENT_ID,
clientSecret: process.env.GITHUB_CLIENT_SECRET,
callbackURL: 'https://myapp.com/auth/github/callback'
},
async (accessToken, refreshToken, profile, done) => {
let user = await db.findUserByGitHubId(profile.id);
if (!user) {
user = await db.createUser({
githubId: profile.id,
name: profile.displayName,
email: profile.emails[0].value,
avatar: profile.photos[0].value,
});
}
return done(null, user);
}
));
// Routes
app.get('/auth/github',
passport.authenticate('github', { scope: ['user:email'] })
);
app.get('/auth/github/callback',
passport.authenticate('github', { failureRedirect: '/login' }),
(req, res) => {
res.redirect('/dashboard');
}
);
8.3 Apple Login Specifics
Apple Login differs from other social logins:
1. Hide My Email (Private Email Relay)
- User can provide a unique relay address instead of real email
- Example: abc123@privaterelay.appleid.com
2. User information provided only on first login
- Name and email are returned only on first login
- You MUST save them on the first response!
3. client_secret is a JWT
- Unlike other providers, you generate client_secret as a JWT
- Signed with keys from Apple Developer Portal
4. Web uses Form POST method
- Callback is delivered via POST
9. Passkey / WebAuthn / FIDO2
9.1 What is Passkey
The future of passwordless authentication.
Log in with biometrics (fingerprint/face), device PIN, or security keys.
Passkey = WebAuthn + FIDO2 + Cloud Sync
Pros:
- Phishing-proof (bound to domain)
- No password leaks
- Improved user experience (login with just touch/face)
- Cross-device support (iCloud Keychain, Google Password Manager)
Browser Support:
- Chrome 108+ (2022.12)
- Safari 16+ (2022.09)
- Firefox 122+ (2024.01)
- Edge 108+ (2022.12)
9.2 Registration Flow
// Server: Generate Registration Options
app.post('/webauthn/register/options', async (req, res) => {
const user = req.user;
const options = {
challenge: crypto.randomBytes(32),
rp: {
name: 'My App',
id: 'myapp.com'
},
user: {
id: Buffer.from(user.id),
name: user.email,
displayName: user.name
},
pubKeyCredParams: [
{ alg: -7, type: 'public-key' }, // ES256
{ alg: -257, type: 'public-key' } // RS256
],
authenticatorSelection: {
authenticatorAttachment: 'platform',
userVerification: 'preferred',
residentKey: 'required'
},
timeout: 60000
};
req.session.challenge = options.challenge;
res.json(options);
});
// Client: Create Passkey
const credential = await navigator.credentials.create({
publicKey: registrationOptions
});
// Server: Complete Registration
app.post('/webauthn/register/verify', async (req, res) => {
const { credential } = req.body;
// Verify challenge, verify origin, extract and save public key
const verified = await verifyRegistration(credential, req.session.challenge);
if (verified) {
await db.saveCredential({
credentialId: credential.id,
publicKey: verified.publicKey,
userId: req.user.id,
counter: verified.counter
});
res.json({ success: true });
}
});
9.3 Authentication Flow
// Server: Generate Authentication Options
app.post('/webauthn/login/options', async (req, res) => {
const options = {
challenge: crypto.randomBytes(32),
rpId: 'myapp.com',
userVerification: 'preferred',
timeout: 60000
};
req.session.challenge = options.challenge;
res.json(options);
});
// Client: Authenticate with Passkey
const assertion = await navigator.credentials.get({
publicKey: authenticationOptions
});
// Server: Verify Authentication
app.post('/webauthn/login/verify', async (req, res) => {
const { assertion } = req.body;
const credential = await db.findCredential(assertion.id);
const verified = await verifyAuthentication(
assertion,
credential.publicKey,
req.session.challenge,
credential.counter
);
if (verified) {
// Update counter (replay attack prevention)
await db.updateCounter(assertion.id, verified.newCounter);
// Issue session/token
const token = jwt.sign({ sub: credential.userId }, privateKey);
res.json({ token });
}
});
10. Multi-Factor Authentication (MFA)
10.1 MFA Method Comparison
| Method | Security Level | UX | Implementation |
|---|---|---|---|
| SMS OTP | Low (SIM swapping) | OK | Low |
| TOTP (Google Authenticator) | Medium | OK | Medium |
| Push Notification (Duo, Okta) | High | Good | High |
| Hardware Key (YubiKey) | Very High | OK | Medium |
| Passkey | Very High | Excellent | Medium |
10.2 TOTP Implementation
const speakeasy = require('speakeasy');
const QRCode = require('qrcode');
// MFA Setup - Generate secret
app.post('/mfa/setup', async (req, res) => {
const secret = speakeasy.generateSecret({
name: `MyApp (${req.user.email})`,
issuer: 'MyApp'
});
// Generate QR code
const qrCodeUrl = await QRCode.toDataURL(secret.otpauth_url);
// Temporarily save secret (before verification)
await db.saveTempMfaSecret(req.user.id, secret.base32);
res.json({
qrCode: qrCodeUrl,
manualEntry: secret.base32
});
});
// MFA Setup - Verify code and activate
app.post('/mfa/verify-setup', async (req, res) => {
const { code } = req.body;
const secret = await db.getTempMfaSecret(req.user.id);
const verified = speakeasy.totp.verify({
secret: secret,
encoding: 'base32',
token: code,
window: 1 // Allow 30 seconds before/after
});
if (verified) {
await db.enableMfa(req.user.id, secret);
// Generate backup codes
const backupCodes = Array.from({ length: 10 }, () =>
crypto.randomBytes(4).toString('hex')
);
await db.saveBackupCodes(req.user.id, backupCodes);
res.json({ success: true, backupCodes });
} else {
res.status(400).json({ error: 'Invalid code' });
}
});
// MFA verification during login
app.post('/auth/mfa-verify', async (req, res) => {
const { code, userId } = req.body;
const user = await db.getUser(userId);
const verified = speakeasy.totp.verify({
secret: user.mfaSecret,
encoding: 'base32',
token: code,
window: 1
});
if (verified) {
const token = jwt.sign({ sub: user.id, mfa: true }, privateKey);
res.json({ token });
} else {
res.status(401).json({ error: 'Invalid MFA code' });
}
});
11. Security Best Practices
11.1 CSRF Prevention
CSRF (Cross-Site Request Forgery):
A malicious site forges requests using an authenticated user's privileges.
Defense methods:
1. SameSite cookie attribute
Set-Cookie: session=abc; SameSite=Strict
2. CSRF Tokens
- Server issues a unique token with the form
- Token included with form submission
- Server validates the token
3. Double Submit Cookie
- Include CSRF token in both cookie and request body
- Verify both values match
4. Origin/Referer validation
- Check that the request Origin header is from an allowed domain
11.2 XSS Prevention and Token Storage
Token storage security comparison by location:
localStorage:
- Vulnerable to XSS (accessible via JavaScript)
- Safe from CSRF
- Strongly not recommended
sessionStorage:
- Vulnerable to XSS
- Not shared between tabs
- Slightly better than localStorage
HttpOnly Cookie:
- Safe from XSS (JavaScript cannot access)
- Needs CSRF protection (SameSite=Strict)
- Most recommended approach
Memory (variable):
- Safest from both XSS/CSRF
- Lost on page refresh
- Usable in SPAs, Refresh Token in HttpOnly cookie
11.3 CORS Configuration
// Express.js CORS configuration
const cors = require('cors');
// Bad: Allow all domains
app.use(cors());
// Good: Allow only specific domains
app.use(cors({
origin: ['https://myapp.com', 'https://admin.myapp.com'],
methods: ['GET', 'POST', 'PUT', 'DELETE'],
allowedHeaders: ['Content-Type', 'Authorization'],
credentials: true, // Allow cookies
maxAge: 86400 // Preflight cache 24 hours
}));
11.4 Security Headers
# Essential security headers
Strict-Transport-Security: max-age=31536000; includeSubDomains; preload
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
X-XSS-Protection: 0
Content-Security-Policy: default-src 'self'; script-src 'self'
Referrer-Policy: strict-origin-when-cross-origin
Permissions-Policy: camera=(), microphone=(), geolocation=()
11.5 Password Policy
// Password hashing with bcrypt
const bcrypt = require('bcrypt');
const SALT_ROUNDS = 12;
// Hash password
async function hashPassword(plainPassword) {
return await bcrypt.hash(plainPassword, SALT_ROUNDS);
}
// Verify password
async function verifyPassword(plainPassword, hashedPassword) {
return await bcrypt.compare(plainPassword, hashedPassword);
}
// Password strength validation
function validatePasswordStrength(password) {
const rules = [
{ test: /.{8,}/, message: 'Minimum 8 characters' },
{ test: /[A-Z]/, message: 'At least 1 uppercase letter' },
{ test: /[a-z]/, message: 'At least 1 lowercase letter' },
{ test: /[0-9]/, message: 'At least 1 number' },
{ test: /[^A-Za-z0-9]/, message: 'At least 1 special character' },
];
const failures = rules.filter(r => !r.test.test(password));
return { valid: failures.length === 0, failures };
}
12. Auth Libraries Comparison
12.1 Major Libraries
| Library | Type | Language | Primary Use Case |
|---|---|---|---|
| NextAuth.js (Auth.js) | Library | JS/TS | Next.js social login |
| Passport.js | Middleware | JS | Express multi-strategy |
| Lucia | Library | JS/TS | Session-based, direct control |
| Clerk | BaaS | JS/TS | Full-stack auth UI |
| Auth0 | IDaaS | Various | Enterprise SSO |
| Keycloak | Self-hosted | Java | Enterprise IdP |
| Supabase Auth | BaaS | JS/TS | Supabase ecosystem |
| Firebase Auth | BaaS | Various | Google ecosystem |
12.2 Selection Criteria
Small project + quick implementation:
-> NextAuth.js / Clerk / Supabase Auth
Direct control + custom requirements:
-> Lucia / Passport.js
Enterprise SSO + SAML:
-> Auth0 / Keycloak
Microservices + self-hosting:
-> Keycloak
Serverless + Google ecosystem:
-> Firebase Auth
13. Session vs JWT Comparison Table
| Dimension | Session-based | JWT-based |
|---|---|---|
| State Management | Stateful (server-stored) | Stateless (self-contained token) |
| Storage Location | Server (Redis/DB) | Client (cookie/memory) |
| Scalability | Requires session sharing | No cross-server sharing needed |
| Security (on theft) | Immediate server invalidation | Valid until expiration |
| Size | Cookie: small (session ID) | Token: large (includes claims) |
| Microservices | Central session store needed | Independent verification per service |
| Mobile Support | Cookie management needed | Authorization header |
| Logout | Delete session (instant) | Blacklist needed |
| Server Load | DB/Redis lookup per request | Signature verification only (CPU) |
| Cross-domain | Cookie domain restrictions | Headers pass freely |
| Implementation | Low complexity | Medium (Refresh Token, etc.) |
| CSRF | Vulnerable (auto cookie send) | Safe (Authorization header) |
| XSS | Safe (HttpOnly cookie) | Vulnerable (if in localStorage) |
| Offline Use | Not possible | Possible (info in token) |
| Debugging | Check server logs | Decode at jwt.io |
14. Quiz
Q1. Authentication vs Authorization
Explain the difference between 401 Unauthorized and 403 Forbidden with specific scenarios.
Answer:
-
401 Unauthorized (Authentication failure): The user has not proven their identity. Examples: API call without Authorization header, using an expired JWT token, wrong password. "I don't know who you are."
-
403 Forbidden (Authorization failure): The user is authenticated but lacks permission for the resource. Examples: regular user calling DELETE /admin/users API, attempting to access another user's private data. "I know who you are, but you don't have permission for this."
Q2. JWT Security
Explain why JWT should not be stored in localStorage and the recommended storage approach.
Answer:
Storing in localStorage is vulnerable to XSS (Cross-Site Scripting) attacks. If a malicious script is injected, it can steal the token via localStorage.getItem('token').
Recommended approach:
- Access Token: Store in memory (JavaScript variable). Lost on page refresh but most secure.
- Refresh Token: Store in HttpOnly + Secure + SameSite=Strict cookie. Inaccessible from JavaScript, safe from XSS.
- When Access Token expires, renew using the Refresh Token cookie.
Q3. OAuth 2.0 PKCE
Explain why PKCE is mandatory for Authorization Code Flow in SPAs.
Answer: SPAs have their source code fully exposed in the browser, making it impossible to securely store a client_secret. Without PKCE, if an Authorization Code is intercepted, an attacker can exchange it for an Access Token.
PKCE solves this:
- Client generates a
code_verifier(random string) - Sends
code_challenge = SHA256(code_verifier)with the authorization request - Sends the original
code_verifierduring token exchange - Server verifies
SHA256(code_verifier) == code_challenge
Even if an attacker intercepts the Authorization Code, they cannot exchange it without the code_verifier.
Q4. Passkey
Explain 3 reasons why Passkey is more secure than passwords.
Answer:
-
Phishing-proof: Passkeys are bound to the registered domain (RP ID). They will not work on fake sites, fundamentally blocking phishing attacks.
-
Safe from server breaches: Only public keys are stored on the server. Even if the DB is leaked, public keys cannot be used to forge authentication. Passwords, while hashed, can be cracked if weak.
-
No reuse: Unique key pairs are generated for each service. A compromise at one service has no impact on others. In contrast, users frequently reuse passwords across multiple sites.
Q5. Session vs JWT
Explain why JWT is advantageous over session-based auth in a microservices environment.
Answer: Why JWT is advantageous in microservices:
-
Independent verification: Each service can independently verify tokens with just the public key. Sessions require all services to share a central session store (Redis).
-
Reduced network overhead: JWT completes authentication with just signature verification, no DB/Redis lookup needed per request.
-
Easy inter-service propagation: Simply pass the JWT in the Authorization header between services. User information is embedded in the token, eliminating additional lookups.
-
Easy scaling: Adding new service instances requires no session store connection setup, just public key distribution.
References
- OAuth 2.0 RFC 6749 - https://tools.ietf.org/html/rfc6749
- JWT RFC 7519 - https://tools.ietf.org/html/rfc7519
- PKCE RFC 7636 - https://tools.ietf.org/html/rfc7636
- OpenID Connect Core - https://openid.net/specs/openid-connect-core-1_0.html
- WebAuthn Specification - https://www.w3.org/TR/webauthn-2/
- FIDO2 Specifications - https://fidoalliance.org/fido2/
- OWASP Authentication Cheat Sheet - https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html
- OWASP Session Management - https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html
- Auth0 Documentation - https://auth0.com/docs
- NextAuth.js (Auth.js) Docs - https://authjs.dev/
- Passkeys.dev - https://passkeys.dev/
- SAML 2.0 Technical Overview - https://docs.oasis-open.org/security/saml/Post2.0/sstc-saml-tech-overview-2.0.html
- OAuth 2.0 Security Best Current Practice - https://datatracker.ietf.org/doc/html/draft-ietf-oauth-security-topics
- Keycloak Documentation - https://www.keycloak.org/documentation