- Published on
OAuth2 & JWT Complete Guide: Everything About Authentication and Authorization for Developers
- Authors

- Name
- Youngju Kim
- @fjvbn20031
- Introduction
- 1. Authentication vs Authorization
- 2. Session vs Token-Based Authentication
- 3. OAuth2 Framework
- 4. OAuth2 Flows Deep Dive
- 5. JWT Deep Dive
- 6. Access Token + Refresh Token Strategy
- 7. OpenID Connect (OIDC)
- 8. Practical Implementation
- 9. Security Vulnerabilities and Defenses
- 10. Production Checklist
- 11. Interview Questions
- 12. Quiz
- References
- Conclusion
Introduction
Authentication and Authorization are the foundation and most critical security components of every web application. OAuth2 has been the de facto authorization standard for over 10 years since being standardized as RFC 6749 in 2012, and JWT has become synonymous with stateless tokens.
However, many developers confuse OAuth2 flows or fall into JWT security pitfalls. This article systematically covers everything from the difference between authentication and authorization, all OAuth2 flows, JWT deep dive, practical implementations (Spring Security, Next.js Auth.js, Go), to security vulnerabilities and defenses.
1. Authentication vs Authorization
Key Differences
| Aspect | Authentication | Authorization |
|---|---|---|
| Question | "Who are you?" | "What can you do?" |
| Purpose | Verify user identity | Verify access rights |
| Timing | Performed first | After authentication |
| Methods | Password, Biometrics, OTP | Roles, Permissions, Policies |
| Protocols | OIDC, SAML, WebAuthn | OAuth2, RBAC, ABAC |
| Analogy | Checking ID | Checking access badge |
Real-World Analogy
Airport Security Check:
1. Authentication: Passport verification
- "Is this person really John Doe?"
- Identity verified with passport (password)
2. Authorization: Boarding pass check
- "Can John Doe board this flight?"
- Access scope determined by boarding pass (permissions)
- Business lounge access? Economy boarding?
Common Misconception
Incorrect: "OAuth2 is an authentication protocol" (X)
Correct: "OAuth2 is an authorization framework" (O)
OAuth2 itself does not answer "Who are you?"
If you need authentication, use OIDC (OpenID Connect) on top of OAuth2.
2. Session vs Token-Based Authentication
Session-Based Authentication
Flow:
1. User logs in (ID/PW)
2. Server creates session (server memory or Redis)
3. Session ID sent to client as cookie
4. Every request includes session ID via cookie
5. Server looks up user info from session store
// Express + express-session example
const session = require('express-session')
const RedisStore = require('connect-redis').default
const redis = require('redis')
const redisClient = redis.createClient()
app.use(
session({
store: new RedisStore({ client: redisClient }),
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
cookie: {
secure: true, // HTTPS only
httpOnly: true, // Block JavaScript access
maxAge: 24 * 60 * 60 * 1000, // 24 hours
sameSite: 'lax', // CSRF prevention
},
})
)
// Login
app.post('/login', async (req, res) => {
const user = await authenticateUser(req.body.email, req.body.password)
if (!user) return res.status(401).json({ error: 'Invalid credentials' })
req.session.userId = user.id
req.session.role = user.role
res.json({ message: 'Logged in successfully' })
})
// Auth middleware
function requireAuth(req, res, next) {
if (!req.session.userId) {
return res.status(401).json({ error: 'Authentication required' })
}
next()
}
Token-Based Authentication
Flow:
1. User logs in (ID/PW)
2. Server creates and signs JWT
3. JWT sent to client
4. Every request includes JWT in Authorization header
5. Server verifies JWT signature (no session store needed)
Comparison
| Feature | Session | Token (JWT) |
|---|---|---|
| State | Stateful (session stored on server) | Stateless (info in token) |
| Storage | Server (memory/Redis) + Client (cookie) | Client (cookie/localStorage) |
| Scalability | Session sync needed (Sticky Session/Redis) | Excellent (signature verification only) |
| Security | Session hijacking risk | Token theft, XSS risk |
| Size | Session ID only (small) | Full JWT (relatively large) |
| Revocation | Instant (delete session) | Difficult (valid until expiry, blacklist needed) |
| Multi-server | Shared store (Redis) needed | No sharing needed |
| Mobile | Cookie management complex | Simple with Authorization header |
| Microservices | Session sharing problem | JWT propagation easy |
When to Use What?
Use Sessions when:
- Traditional server-side rendered web apps (MPA)
- Instant session revocation is critical
- Single server or few servers
Use Tokens (JWT) when:
- SPA (React, Vue, Angular)
- Mobile apps
- Microservices architecture
- Third-party API access
- Serverless environments
3. OAuth2 Framework
Four Roles
1. Resource Owner
-> The user themselves. "The entity that grants access to my data"
2. Client
-> The application requesting access. "The app that needs user data"
3. Authorization Server
-> Authenticates users and issues tokens. "Google, GitHub, Keycloak, etc."
4. Resource Server
-> Hosts protected resources. "The API server"
OAuth2 Endpoints
| Endpoint | Purpose |
|---|---|
| /authorize | User authentication and authorization code issuance |
| /token | Exchange authorization code for Access Token |
| /revoke | Token revocation |
| /introspect | Token validity check (RFC 7662) |
| /userinfo | OIDC user information retrieval |
| /.well-known/openid-configuration | OIDC Discovery document |
4. OAuth2 Flows Deep Dive
Authorization Code Flow (For Server Applications)
The most fundamental and secure flow. Suitable for server-side applications.
Step-by-step flow:
1. Client -> Auth Server (Authorization Request)
GET /authorize?
response_type=code&
client_id=CLIENT_ID&
redirect_uri=https://app.example.com/callback&
scope=read write&
state=random_csrf_token
2. User logs in and consents at Authorization Server
3. Auth Server -> Client (Authorization Code)
302 Redirect: https://app.example.com/callback?
code=AUTHORIZATION_CODE&
state=random_csrf_token
4. Client -> Auth Server (Token Exchange) [back channel, server-to-server]
POST /token
grant_type=authorization_code&
code=AUTHORIZATION_CODE&
redirect_uri=https://app.example.com/callback&
client_id=CLIENT_ID&
client_secret=CLIENT_SECRET
5. Auth Server -> Client (Token Response)
{
"access_token": "eyJhbGciOiJSUzI...",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "dGhpcyBpcyBhIHJl...",
"scope": "read write"
}
Authorization Code + PKCE (For SPA, Mobile)
SPAs and mobile apps cannot securely store Client Secrets, so they use PKCE (Proof Key for Code Exchange).
Additional PKCE steps:
1. Client generates code_verifier (43-128 char random string)
2. code_challenge = Base64URL(SHA256(code_verifier))
3. Include code_challenge + code_challenge_method=S256 in auth request
4. Send code_verifier during token exchange (server verifies)
// PKCE Implementation (JavaScript)
function generateCodeVerifier() {
const array = new Uint8Array(32)
crypto.getRandomValues(array)
return base64URLEncode(array)
}
async function generateCodeChallenge(verifier) {
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) {
return btoa(String.fromCharCode(...buffer))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, '')
}
// Usage
const codeVerifier = generateCodeVerifier()
const codeChallenge = await generateCodeChallenge(codeVerifier)
// Authorization request
const authUrl =
`https://auth.example.com/authorize?` +
`response_type=code&` +
`client_id=CLIENT_ID&` +
`redirect_uri=https://app.example.com/callback&` +
`scope=openid profile email&` +
`code_challenge=${codeChallenge}&` +
`code_challenge_method=S256&` +
`state=${generateState()}`
Client Credentials Flow (Server-to-Server)
Used for server-to-server communication without user involvement.
Steps:
1. Client -> Auth Server (Direct Token Request)
POST /token
grant_type=client_credentials&
client_id=SERVICE_A_ID&
client_secret=SERVICE_A_SECRET&
scope=api.read
2. Auth Server -> Client (Access Token)
{
"access_token": "eyJhbGciOiJSUzI...",
"token_type": "Bearer",
"expires_in": 3600
}
(No Refresh Token -- request again on expiry)
# Python Client Credentials request
import requests
response = requests.post('https://auth.example.com/token', data={
'grant_type': 'client_credentials',
'client_id': 'service-a',
'client_secret': 'secret-value',
'scope': 'api.read api.write',
})
token = response.json()['access_token']
Device Code Flow (IoT, CLI)
Used for devices where keyboard input is difficult (smart TV, CLI tools).
Steps:
1. Device -> Auth Server (Device Code Request)
POST /device/code
client_id=TV_APP_ID&scope=openid profile
2. Auth Server -> Device (Device Code + User Code)
{
"device_code": "DEVICE_CODE_HERE",
"user_code": "ABCD-1234",
"verification_uri": "https://auth.example.com/device",
"expires_in": 1800,
"interval": 5
}
3. Device displays: "Go to https://auth.example.com/device and enter ABCD-1234"
4. User visits URL on another device (phone, PC) -> enters code -> logs in -> consents
5. Device polls for token periodically
POST /token
grant_type=urn:ietf:params:oauth:grant-type:device_code&
device_code=DEVICE_CODE_HERE&
client_id=TV_APP_ID
6. Receives Access Token after authorization
Implicit Flow (Deprecated)
Why Implicit Flow was deprecated:
1. Access Token exposed in URL Fragment (#access_token=...)
2. Token recorded in browser history
3. Token leakage via Referrer headers
4. Vulnerable to token theft attacks
5. Cannot use Refresh Tokens
Alternative: Authorization Code + PKCE
-> Browser-based apps should use this approach.
-> Implicit Flow officially removed in OAuth 2.1 draft
Flow Selection Guide
| Application Type | Recommended Flow |
|---|---|
| Server-side Web Apps (Node, Spring, Django) | Authorization Code |
| SPA (React, Vue, Angular) | Authorization Code + PKCE |
| Mobile Apps (iOS, Android) | Authorization Code + PKCE |
| Server-to-Server (Microservices, Batch) | Client Credentials |
| IoT, Smart TV, CLI | Device Code |
| Legacy (Do not use) |
5. JWT Deep Dive
JWT Structure
JWT consists of 3 parts separated by dots (.).
eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.
eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMn0.
POstGetfAytaZS82wHcjoTyoqhMyxXiWdR7Nn7A29DNSl0EiXLdwJ6xC6AfgZWF1bOsS...
[Header].[Payload].[Signature]
Header (Base64URL):
{
"alg": "RS256", // Signing algorithm
"typ": "JWT", // Token type
"kid": "key-id-1" // Key ID (for key rotation)
}
Payload (Base64URL):
{
"sub": "1234567890", // Subject (User ID)
"name": "John Doe",
"email": "john@example.com",
"role": "admin",
"iss": "https://auth.example.com", // Issuer
"aud": "https://api.example.com", // Audience
"iat": 1516239022, // Issued At
"exp": 1516242622, // Expiration
"jti": "unique-token-id" // JWT ID (unique identifier)
}
Signature:
RSASHA256(
base64UrlEncode(header) + "." + base64UrlEncode(payload),
privateKey
)
Signing Algorithm Comparison
| Algorithm | Type | Key | When to Use |
|---|---|---|---|
| HS256 | Symmetric | Single secret key | Single server, internal systems |
| RS256 | Asymmetric | Public + Private key | Microservices, external verification |
| ES256 | Asymmetric (ECDSA) | Public + Private key | Mobile, performance-critical |
| EdDSA | Asymmetric (Ed25519) | Public + Private key | Latest, high performance, high security |
HS256 vs RS256:
HS256 (Symmetric):
- Sign: Sign with secretKey
- Verify: Verify with same secretKey
- Problem: All services must know secretKey -> leak risk
RS256 (Asymmetric):
- Sign: Sign with privateKey (only auth server has it)
- Verify: Verify with publicKey (anyone can)
- Advantage: Other services can verify tokens without privateKey exposure
- Recommended: Microservices environments
JWT Verification Code
// Node.js JWT verification (jsonwebtoken)
const jwt = require('jsonwebtoken')
const jwksClient = require('jwks-rsa')
// Get public key from JWKS (JSON Web Key Set)
const client = jwksClient({
jwksUri: 'https://auth.example.com/.well-known/jwks.json',
cache: true,
rateLimit: true,
})
function getSigningKey(header, callback) {
client.getSigningKey(header.kid, (err, key) => {
if (err) return callback(err)
callback(null, key.getPublicKey())
})
}
// JWT verification middleware
function verifyToken(req, res, next) {
const authHeader = req.headers.authorization
if (!authHeader?.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Missing token' })
}
const token = authHeader.substring(7)
jwt.verify(
token,
getSigningKey,
{
algorithms: ['RS256'],
issuer: 'https://auth.example.com',
audience: 'https://api.example.com',
},
(err, decoded) => {
if (err) {
if (err.name === 'TokenExpiredError') {
return res.status(401).json({ error: 'Token expired' })
}
return res.status(401).json({ error: 'Invalid token' })
}
req.user = decoded
next()
}
)
}
JWT Pitfalls
Never do:
1. Store passwords or PII in JWT payload (Base64 is NOT encryption!)
2. Allow "alg": "none" (accept unsigned tokens)
3. Ignore algorithm confusion attacks (RS256 -> HS256 downgrade)
4. Issue tokens without exp claim (eternally valid tokens)
5. Grow token size indefinitely (sent with every request)
Always do:
1. Always verify signatures (fix algorithm)
2. Validate exp, iss, aud claims
3. Use HTTPS only
4. Set appropriate expiration times
5. Include only minimum necessary claims
6. Access Token + Refresh Token Strategy
Token Lifecycle
Access Token:
- Short lifespan: 15 min to 1 hour
- Used for every API request
- Renewed with Refresh Token when expired
Refresh Token:
- Long lifespan: 7 days to 30 days
- Used only to renew Access Tokens
- Protected more strictly
Refresh Token Flow
1. Client makes API request with Access Token
2. Server returns 401 (token expired)
3. Client requests new Access Token with Refresh Token
POST /token
grant_type=refresh_token&
refresh_token=REFRESH_TOKEN_VALUE&
client_id=CLIENT_ID
4. Server returns new Access Token (+ new Refresh Token)
5. Client retries with new Access Token
Token Storage Locations
| Storage Location | XSS Safe | CSRF Safe | Recommended |
|---|---|---|---|
| httpOnly Cookie | Yes (no JS access) | SameSite prevents | Recommended |
| localStorage | No (JS accessible) | Yes (no auto-send) | Not recommended |
| sessionStorage | No | Yes | Not recommended |
| Memory (JS variable) | Yes (not persisted) | Yes | Supplementary use |
// Recommended: Store tokens in httpOnly cookies (server-side)
res.cookie('access_token', accessToken, {
httpOnly: true, // No JavaScript access
secure: true, // HTTPS only
sameSite: 'lax', // CSRF prevention
maxAge: 15 * 60 * 1000, // 15 minutes
path: '/',
})
res.cookie('refresh_token', refreshToken, {
httpOnly: true,
secure: true,
sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
path: '/api/auth/refresh', // Only sent to refresh endpoint
})
Refresh Token Rotation
Standard approach:
Refresh Token A -> New Access Token issued (Refresh Token A kept)
Problem: If Refresh Token is stolen, attacker can keep using it
Rotation approach:
Refresh Token A -> New Access Token + New Refresh Token B issued
(Refresh Token A invalidated)
Benefit: Immediate detection if stolen Refresh Token is used
Detection mechanism:
1. Request comes with already-used Refresh Token A
2. Server: "This token was already rotated -> possible theft!"
3. Invalidate ALL Refresh Tokens for that user
4. Require user to re-login
// Refresh Token Rotation implementation
async function refreshTokenHandler(req, res) {
const { refresh_token } = req.body
// Look up Refresh Token in DB
const storedToken = await db.refreshTokens.findOne({ token: refresh_token })
if (!storedToken) {
return res.status(401).json({ error: 'Invalid refresh token' })
}
// Check if token was already used (Reuse Detection)
if (storedToken.used) {
// Possible theft -> invalidate all tokens for user
await db.refreshTokens.deleteMany({ userId: storedToken.userId })
return res.status(401).json({ error: 'Token reuse detected. All sessions revoked.' })
}
// Mark existing token as used
await db.refreshTokens.updateOne({ token: refresh_token }, { used: true, usedAt: new Date() })
// Issue new tokens
const newAccessToken = generateAccessToken(storedToken.userId)
const newRefreshToken = generateRefreshToken()
await db.refreshTokens.insertOne({
token: newRefreshToken,
userId: storedToken.userId,
parentToken: refresh_token,
used: false,
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
})
res.json({
access_token: newAccessToken,
refresh_token: newRefreshToken,
})
}
7. OpenID Connect (OIDC)
What is OIDC?
OIDC adds an authentication layer on top of OAuth2. While OAuth2 focuses on "What can you do?" (authorization), OIDC provides "Who are you?" (authentication).
ID Token
The core of OIDC is the ID Token (JWT).
{
"iss": "https://accounts.google.com",
"sub": "110169484474386276334",
"aud": "my-client-id",
"exp": 1678886400,
"iat": 1678882800,
"nonce": "random-nonce-value",
"name": "John Doe",
"email": "john@gmail.com",
"email_verified": true,
"picture": "https://lh3.googleusercontent.com/photo.jpg"
}
OIDC Scopes
| Scope | Included Claims |
|---|---|
| openid | sub (required) |
| profile | name, family_name, given_name, picture, etc. |
| email, email_verified | |
| address | address |
| phone | phone_number, phone_number_verified |
OIDC Discovery
All OIDC providers serve a Discovery document:
GET https://accounts.google.com/.well-known/openid-configuration
Included information:
- issuer: Provider identifier
- authorization_endpoint: Authorization endpoint
- token_endpoint: Token endpoint
- userinfo_endpoint: User info endpoint
- jwks_uri: Public key set URL
- scopes_supported: Supported scopes
- response_types_supported: Supported response types
Access Token vs ID Token
| Feature | Access Token | ID Token |
|---|---|---|
| Purpose | API access authorization | User authentication |
| Recipient | Resource Server (API) | Client App |
| Contains | Scope, permissions | User profile |
| Validated by | Resource Server | Client |
| Send to API | Yes | No (never send ID Token to API) |
8. Practical Implementation
Spring Security OAuth2
// Spring Boot 3 + Spring Security 6 Configuration
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/public/**").permitAll()
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.anyRequest().authenticated()
)
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(jwt -> jwt
.jwtAuthenticationConverter(jwtAuthenticationConverter())
)
)
.sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
);
return http.build();
}
@Bean
public JwtAuthenticationConverter jwtAuthenticationConverter() {
JwtGrantedAuthoritiesConverter converter = new JwtGrantedAuthoritiesConverter();
converter.setAuthoritiesClaimName("roles");
converter.setAuthorityPrefix("ROLE_");
JwtAuthenticationConverter jwtConverter = new JwtAuthenticationConverter();
jwtConverter.setJwtGrantedAuthoritiesConverter(converter);
return jwtConverter;
}
@Bean
public JwtDecoder jwtDecoder() {
return NimbusJwtDecoder.withJwkSetUri(
"https://auth.example.com/.well-known/jwks.json"
).build();
}
}
# application.yml
spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: https://auth.example.com
jwk-set-uri: https://auth.example.com/.well-known/jwks.json
Next.js Auth.js v5
// auth.ts (Auth.js v5 configuration)
import NextAuth from 'next-auth'
import GitHub from 'next-auth/providers/github'
import Google from 'next-auth/providers/google'
import Credentials from 'next-auth/providers/credentials'
export const { handlers, signIn, signOut, auth } = NextAuth({
providers: [
GitHub({
clientId: process.env.GITHUB_ID,
clientSecret: process.env.GITHUB_SECRET,
}),
Google({
clientId: process.env.GOOGLE_ID,
clientSecret: process.env.GOOGLE_SECRET,
}),
Credentials({
name: 'credentials',
credentials: {
email: { label: 'Email', type: 'email' },
password: { label: 'Password', type: 'password' },
},
async authorize(credentials) {
const user = await verifyCredentials(
credentials.email as string,
credentials.password as string
)
return user || null
},
}),
],
callbacks: {
async jwt({ token, user, account }) {
if (user) {
token.id = user.id
token.role = user.role
}
if (account) {
token.accessToken = account.access_token
}
return token
},
async session({ session, token }) {
session.user.id = token.id as string
session.user.role = token.role as string
return session
},
},
pages: {
signIn: '/login',
error: '/auth/error',
},
session: {
strategy: 'jwt',
maxAge: 24 * 60 * 60, // 24 hours
},
})
// middleware.ts
import { auth } from './auth'
export default auth((req) => {
const isLoggedIn = !!req.auth
const isProtected = req.nextUrl.pathname.startsWith('/dashboard')
if (isProtected && !isLoggedIn) {
return Response.redirect(new URL('/login', req.nextUrl))
}
})
export const config = {
matcher: ['/dashboard/:path*', '/api/protected/:path*'],
}
Go + OAuth2
package main
import (
"context"
"encoding/json"
"fmt"
"net/http"
"github.com/coreos/go-oidc/v3/oidc"
"golang.org/x/oauth2"
)
var (
oauth2Config *oauth2.Config
oidcVerifier *oidc.IDTokenVerifier
)
func init() {
ctx := context.Background()
provider, _ := oidc.NewProvider(ctx, "https://accounts.google.com")
oauth2Config = &oauth2.Config{
ClientID: "CLIENT_ID",
ClientSecret: "CLIENT_SECRET",
RedirectURL: "http://localhost:8080/callback",
Endpoint: provider.Endpoint(),
Scopes: []string{oidc.ScopeOpenID, "profile", "email"},
}
oidcVerifier = provider.Verifier(&oidc.Config{
ClientID: "CLIENT_ID",
})
}
func handleLogin(w http.ResponseWriter, r *http.Request) {
state := generateState()
http.Redirect(w, r, oauth2Config.AuthCodeURL(state), http.StatusFound)
}
func handleCallback(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
code := r.URL.Query().Get("code")
// Token exchange
token, err := oauth2Config.Exchange(ctx, code)
if err != nil {
http.Error(w, "Token exchange failed", http.StatusInternalServerError)
return
}
// Extract and verify ID Token
rawIDToken, ok := token.Extra("id_token").(string)
if !ok {
http.Error(w, "No ID token", http.StatusInternalServerError)
return
}
idToken, err := oidcVerifier.Verify(ctx, rawIDToken)
if err != nil {
http.Error(w, "ID token verification failed", http.StatusInternalServerError)
return
}
// Extract claims
var claims struct {
Email string `json:"email"`
Name string `json:"name"`
Picture string `json:"picture"`
}
idToken.Claims(&claims)
json.NewEncoder(w).Encode(claims)
}
func main() {
http.HandleFunc("/login", handleLogin)
http.HandleFunc("/callback", handleCallback)
fmt.Println("Server running on :8080")
http.ListenAndServe(":8080", nil)
}
9. Security Vulnerabilities and Defenses
CSRF (Cross-Site Request Forgery)
Attack:
User visits malicious site while logged in
-> Malicious site uses user cookies to make API requests
Defense:
1. Set SameSite cookie attribute (Lax or Strict)
2. Use CSRF tokens
3. Validate Origin/Referer headers
4. Use Authorization header (instead of cookies)
XSS Token Theft
Attack:
Execute JavaScript through XSS vulnerability
-> Steal tokens from localStorage
Defense:
1. Store tokens in httpOnly cookies (no JavaScript access)
2. Set CSP (Content Security Policy) headers
3. Sanitize input values
4. Use XSS prevention libraries like DOMPurify
Token Leakage
Attack:
Include token in URL parameters
-> Leaked via Referrer headers, browser history, server logs
Defense:
1. Never include tokens in URLs
2. Use Authorization header or httpOnly cookies
3. Set Referrer-Policy header
Replay Attack
Attack:
Intercept a valid token and reuse it
Defense:
1. Short token expiration time
2. Use jti (JWT ID) claim for one-time verification
3. Use nonce (OIDC)
4. Token binding (DPoP -- Demonstration of Proof-of-Possession)
JWT Algorithm Confusion Attack
Attack:
In RS256 environment, attacker changes alg to HS256
-> Uses public key as HS256 secret to forge signatures
Defense:
1. Fix allowed algorithms on server (algorithms: ['RS256'])
2. Never trust the alg claim
3. Use latest JWT libraries
OWASP Top 10 Authentication Related
A07:2021 -- Identification and Authentication Failures
Checklist:
1. Don't allow weak passwords
2. Prevent brute force attacks (account lockout, Rate Limiting)
3. Support Multi-Factor Authentication (MFA)
4. Prevent session fixation (change session ID on login)
5. Secure password storage (bcrypt, Argon2)
6. Implement token/session expiration
7. Fully invalidate tokens/sessions on logout
10. Production Checklist
Security Basics:
[ ] HTTPS required (HTTP redirect)
[ ] Appropriate token expiration (Access: 15min, Refresh: 7 days)
[ ] httpOnly + Secure + SameSite cookies
[ ] CORS correctly configured
Token Management:
[ ] Refresh Token Rotation applied
[ ] Token blacklist/revocation mechanism
[ ] JWT signing algorithm fixed (RS256/ES256)
[ ] Key rotation plan established
Input Validation:
[ ] redirect_uri whitelist validation
[ ] state parameter for CSRF prevention
[ ] PKCE applied (SPA, mobile)
[ ] Input sanitization
Monitoring:
[ ] Login failure monitoring and alerts
[ ] Anomalous token usage pattern detection
[ ] Rate Limiting applied
[ ] Audit logging
Infrastructure:
[ ] Secret management (AWS Secrets Manager, Vault)
[ ] Sensitive info in environment variables
[ ] Regular security audits
11. Interview Questions
Q1. Why is Authorization Code Flow safer than Implicit Flow?
Authorization Code Flow exchanges the authorization code for tokens via back channel (server-to-server), so tokens are never exposed in the browser. Implicit Flow directly exposes tokens in URL Fragments, making them vulnerable to browser history, Referrer headers, and man-in-the-middle attacks.
Q2. Is the JWT payload encrypted?
No. The JWT payload is only Base64URL encoded, meaning anyone can decode it. The signature guarantees integrity, not confidentiality. If payload encryption is needed, use JWE (JSON Web Encryption).
Q3. Why is PKCE necessary?
SPAs and mobile apps cannot securely store Client Secrets. PKCE uses dynamically generated code_verifier/code_challenge pairs so that even if the authorization code is intercepted, it cannot be exchanged for tokens. It prevents Authorization Code Interception Attacks.
Q4. What is an appropriate Access Token expiration time?
Typically 15 minutes to 1 hour. Shorter is better for security but worse for user experience (frequent renewals). Use with Refresh Tokens to balance UX and security. Highly sensitive operations (banking) should use 5 minutes; general apps should use 15-30 minutes.
Q5. What are the differences between Session-based and Token-based auth?
Session-based stores state on the server (Stateful) and only passes a session ID to the client. Instant revocation is possible but session sync is needed for scaling. Token-based includes info in the token (Stateless) requiring no server storage. Excellent scalability but instant revocation is difficult.
Q6. What is Refresh Token Rotation?
A technique where each Refresh Token use issues a new Refresh Token and invalidates the old one. If a stolen Refresh Token is used, it has already been rotated so it can be detected, and all tokens for that user can be invalidated to minimize damage.
Q7. What is the difference between OAuth2 and OIDC?
OAuth2 is an authorization framework that grants resource access permissions. OIDC adds an authentication layer on top of OAuth2, confirming user identity through ID Tokens. With OAuth2 alone you cannot know who the user is, but with OIDC you can.
Q8. Why should you not store JWT in localStorage?
localStorage is accessible via JavaScript, making tokens vulnerable to XSS attacks. Storing in httpOnly cookies prevents JavaScript access, protecting against XSS. The SameSite attribute also prevents CSRF.
Q9. What is the difference between HS256 and RS256?
HS256 is a symmetric algorithm using one secret key for both signing and verification. Suitable for single-server environments. RS256 is an asymmetric algorithm using a private key to sign and a public key to verify. In microservices, only the auth server holds the private key while other services verify with the public key.
Q10. What is the relationship between CORS and OAuth2?
When SPAs request the OAuth2 token endpoint, CORS configuration is required. The auth server must allow the SPA Origin. However, in Authorization Code Flow, token exchange happens on the backend, so there are no CORS issues. For SPAs using PKCE that request tokens from the front-end directly, CORS configuration is essential.
Q11. Can you send an ID Token to an API server?
No. ID Tokens are meant for client apps to verify user identity, while Access Tokens should be used for API access. The audience (aud) of an ID Token is the client app, not the API server. Mixing them creates security issues.
Q12. When do you use Client Credentials Flow?
For server-to-server communication (Machine-to-Machine) without user involvement. Examples: microservice API calls, batch jobs, cron jobs. Since there is no user context, there is no Refresh Token, and you simply request again on expiry.
Q13. What is the JWT Algorithm Confusion Attack?
In an RS256 environment, an attacker changes the JWT alg header to HS256 and uses the public key (which is publicly available) as the HS256 secret to create a valid signature. If the server trusts the alg claim, it accepts the forged token. Defense: Fix allowed algorithms on the server.
Q14. How do you implement token revocation?
JWT is Stateless so the server cannot directly revoke it. Methods: 1) Token blacklist (store revoked jti in Redis), 2) Short expiration + Refresh Token revocation, 3) Token version (per-user token_version field, reject on version mismatch). Method 2 is most practical.
Q15. What is DPoP (Demonstration of Proof-of-Possession)?
A mechanism where even if a token is stolen, the attacker cannot use it because the client must submit proof signed with its private key when using the token. The Access Token and DPoP Proof are submitted together, ensuring only the token owner can use it. Defined in RFC 9449.
12. Quiz
Q1. What are the four roles in OAuth2?
Resource Owner (the user), Client (the application), Authorization Server (issues tokens), and Resource Server (the API server).
Q2. What are the three components of JWT?
Header (algorithm and type info), Payload (claims including user info and expiration), and Signature (integrity verification). Each is Base64URL encoded and separated by dots (.).
Q3. Why are Refresh Tokens necessary?
To keep Access Token expiration times short while maintaining good user experience. When an Access Token expires, a Refresh Token obtains a new Access Token without requiring the user to log in again.
Q4. What is the difference between OIDC ID Token and Access Token?
ID Token is a JWT containing user authentication info, used by the client app for identity verification. Access Token represents API access authorization and is sent to the resource server. ID Tokens should never be sent to API servers.
Q5. What is the role of the state parameter?
It prevents CSRF (Cross-Site Request Forgery) attacks. A random value is included as state in the authorization request, and the callback verifies the same value is returned. This distinguishes forged authorization responses from legitimate ones.
References
- RFC 6749: The OAuth 2.0 Authorization Framework
- RFC 7519: JSON Web Token (JWT)
- RFC 7636: Proof Key for Code Exchange (PKCE)
- RFC 7662: OAuth 2.0 Token Introspection
- RFC 9449: OAuth 2.0 Demonstrating Proof of Possession (DPoP)
- RFC 9457: Problem Details for HTTP APIs
- OpenID Connect Core 1.0
- OAuth 2.1 Draft
- OWASP Authentication Cheat Sheet
- Auth0 Documentation
- Keycloak Documentation
Conclusion
Authentication and authorization are the foundation of all application security. OAuth2 and JWT are powerful, but if not implemented correctly, they become security vulnerabilities themselves.
Key takeaways:
- OAuth2 is an authorization framework -- use OIDC for authentication
- Authorization Code + PKCE is the standard for SPA/mobile
- JWT payload is encoded, not encrypted -- never store sensitive data
- Store tokens in httpOnly cookies -- localStorage is vulnerable to XSS
- Apply Refresh Token Rotation for theft detection
- Fix signing algorithms (RS256/ES256) to prevent algorithm confusion attacks
- Short Access Token lifespan + Refresh Tokens for balance
Build secure and user-friendly authentication systems based on this guide.