Skip to content
Published on

OAuth2 & JWT Complete Guide: Everything About Authentication and Authorization for Developers

Authors

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

AspectAuthenticationAuthorization
Question"Who are you?""What can you do?"
PurposeVerify user identityVerify access rights
TimingPerformed firstAfter authentication
MethodsPassword, Biometrics, OTPRoles, Permissions, Policies
ProtocolsOIDC, SAML, WebAuthnOAuth2, RBAC, ABAC
AnalogyChecking IDChecking 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

FeatureSessionToken (JWT)
StateStateful (session stored on server)Stateless (info in token)
StorageServer (memory/Redis) + Client (cookie)Client (cookie/localStorage)
ScalabilitySession sync needed (Sticky Session/Redis)Excellent (signature verification only)
SecuritySession hijacking riskToken theft, XSS risk
SizeSession ID only (small)Full JWT (relatively large)
RevocationInstant (delete session)Difficult (valid until expiry, blacklist needed)
Multi-serverShared store (Redis) neededNo sharing needed
MobileCookie management complexSimple with Authorization header
MicroservicesSession sharing problemJWT 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

EndpointPurpose
/authorizeUser authentication and authorization code issuance
/tokenExchange authorization code for Access Token
/revokeToken revocation
/introspectToken validity check (RFC 7662)
/userinfoOIDC user information retrieval
/.well-known/openid-configurationOIDC 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 TypeRecommended 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, CLIDevice Code
Legacy (Do not use)Implicit (Deprecated)

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

AlgorithmTypeKeyWhen to Use
HS256SymmetricSingle secret keySingle server, internal systems
RS256AsymmetricPublic + Private keyMicroservices, external verification
ES256Asymmetric (ECDSA)Public + Private keyMobile, performance-critical
EdDSAAsymmetric (Ed25519)Public + Private keyLatest, 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 LocationXSS SafeCSRF SafeRecommended
httpOnly CookieYes (no JS access)SameSite preventsRecommended
localStorageNo (JS accessible)Yes (no auto-send)Not recommended
sessionStorageNoYesNot recommended
Memory (JS variable)Yes (not persisted)YesSupplementary 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

ScopeIncluded Claims
openidsub (required)
profilename, family_name, given_name, picture, etc.
emailemail, email_verified
addressaddress
phonephone_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

FeatureAccess TokenID Token
PurposeAPI access authorizationUser authentication
RecipientResource Server (API)Client App
ContainsScope, permissionsUser profile
Validated byResource ServerClient
Send to APIYesNo (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
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:

  1. OAuth2 is an authorization framework -- use OIDC for authentication
  2. Authorization Code + PKCE is the standard for SPA/mobile
  3. JWT payload is encoded, not encrypted -- never store sensitive data
  4. Store tokens in httpOnly cookies -- localStorage is vulnerable to XSS
  5. Apply Refresh Token Rotation for theft detection
  6. Fix signing algorithms (RS256/ES256) to prevent algorithm confusion attacks
  7. Short Access Token lifespan + Refresh Tokens for balance

Build secure and user-friendly authentication systems based on this guide.