Introduction: Navigating the World of Modern Identity

Welcome back, future security champions! In our journey to build secure web applications, understanding how users prove who they are (authentication) and what they’re allowed to do (authorization) is absolutely fundamental. Gone are the days when a simple username/password and a session cookie were enough for every scenario. Modern web applications are distributed, often involving multiple services, APIs, and third-party integrations.

In this chapter, we’ll dive deep into the contemporary standards that power secure identity management: OAuth 2.0, OpenID Connect (OIDC), and JSON Web Tokens (JWTs). We’ll explore what each one is, why they’re crucial for today’s web, and how they work together to create robust and flexible authentication and authorization systems. By the end, you’ll have a clear understanding of these powerful tools and how to apply them securely in your own projects.

Before we begin, a basic understanding of HTTP requests, client-server architecture, and APIs will be helpful. If you’ve been following along, you’re already well-equipped! Let’s demystify these essential security concepts.

Core Concepts: Who Are You, and What Can You Do?

Let’s start by clarifying two terms that are often used interchangeably but have distinct meanings: authentication and authorization.

Authentication vs. Authorization: A Quick Refresher

Imagine you’re entering a secure building.

  • Authentication (AuthN): “Who are you?”

    • This is the process of verifying a user’s identity. When you show your ID card at the building’s entrance, you’re authenticating yourself. The system checks if the ID is valid and if you are indeed the person depicted on it.
    • In web terms: Logging in with a username and password, using a fingerprint, or a facial scan.
  • Authorization (AuthZ): “What are you allowed to do?”

    • Once inside, your ID might grant you access to certain floors or rooms, but not others. You might be authorized to enter the common areas but not the server room.
    • In web terms: After logging in, the application determines if you can view a specific page, edit a profile, or delete a record.

These two concepts are the bedrock of access control. Now, let’s explore the modern protocols that help us implement them securely.

The Power of OAuth 2.0: Delegated Authorization

OAuth 2.0 is one of the most widely misunderstood protocols. Here’s the critical takeaway: OAuth 2.0 is an authorization framework, not an authentication protocol. It’s designed for delegated authorization, allowing a third-party application to access a user’s resources hosted by another service without ever exposing the user’s primary credentials to the third party.

What is OAuth 2.0?

Think of it like giving a valet your car keys. You trust the valet (the “client application”) to drive and park your car (access your “resource”), but you don’t give them your house keys (your primary credentials). If the valet abuses the keys, you can revoke just those car keys, not change your entire lock system.

In the digital world, this means you can allow a photo printing app (the “client”) to access your photos on Google Photos (the “resource server”) without giving the photo app your Google username and password.

Key Roles in OAuth 2.0

OAuth 2.0 defines four main roles:

  1. Resource Owner: This is you, the user, who owns the data (e.g., your photos on Google Photos).
  2. Client: The application that wants to access your resources (e.g., the photo printing app).
  3. Authorization Server: The service that authenticates the Resource Owner and issues access tokens to the Client (e.g., Google’s identity service).
  4. Resource Server: The service that hosts the protected resources and accepts access tokens to grant access (e.g., Google Photos API).

Key Concepts: Access Tokens and Refresh Tokens

  • Access Token: A credential that represents the Resource Owner’s authorization to access protected resources. It’s usually short-lived and opaque to the client (meaning the client doesn’t need to understand its internal structure, just use it).
  • Refresh Token: A credential used by the Client to obtain new access tokens when the current one expires, without requiring the Resource Owner to re-authenticate. Refresh tokens are typically long-lived and should be handled with extreme care.

Common Flows (Grant Types)

OAuth 2.0 defines several “grant types” or “flows” for different client types and use cases. As of 2026, the following are the most relevant and secure:

  1. Authorization Code Flow with PKCE (Proof Key for Code Exchange):

    • Best practice for public clients (like Single Page Applications (SPAs) running in browsers, or mobile apps).
    • Why PKCE? It adds a layer of security to prevent “authorization code interception attacks” where a malicious application could steal an authorization code. It ensures that the same client that initiated the flow is the one exchanging the code for tokens.
    • How it works (simplified):
      1. Your app (Client) redirects the user to the Authorization Server.
      2. The user logs in and grants permission.
      3. The Authorization Server redirects the user back to your app with an authorization code.
      4. Your app immediately exchanges this authorization code for an access token (and often a refresh token) with the Authorization Server directly from your backend (or securely from the client with PKCE).
      5. Your app uses the access token to call the Resource Server’s APIs.
  2. Client Credentials Flow:

    • Used for machine-to-machine communication where there is no user involved (e.g., a backend service calling another backend service). The client authenticates itself directly to the Authorization Server.

Deprecated Flows to Avoid (as of 2026)

  • Implicit Flow: Previously used for SPAs, but deprecated due to inherent security risks, primarily the vulnerability of access tokens being exposed in the browser’s URL history or referrer headers. Do not use this for new applications. Always prefer Authorization Code Flow with PKCE.

Visualizing the Authorization Code Flow with PKCE

Let’s look at a simplified diagram of how the Authorization Code Flow with PKCE works:

flowchart TD subgraph Frontend_SPA["SPA Mobile App"] A[User initiates login] --> B{Generate PKCE Code} B --> C[Redirect to Auth Server] C --> D(Authorization Server) end subgraph Backend_API["Your API"] F[Your Backend] end D --> E[Receive Authorization Code] E --->|Send Auth Code and| F F --> D D --->|Access Token Refresh Token| F F --->|Access Token to Frontend| E E --> G[Use Access Token API Calls] G --> H(Resource Server API) H --->|Protected Resource Data| G

Explanation of the diagram:

  1. User initiates login: In your React/Angular app, a user clicks “Login with Google” or similar.
  2. Generate PKCE: Your frontend library or code generates a code_verifier (a random string) and a code_challenge (a hashed version of the verifier).
  3. Redirect to Auth Server: Your app redirects the user’s browser to the Authorization Server’s /authorize endpoint, including the code_challenge.
  4. User interaction: The user logs into the Authorization Server (e.g., Google) and grants permission to your app.
  5. Redirect back with Code: The Authorization Server redirects the user back to your app’s pre-registered redirect URI, including a one-time authorization code.
  6. Exchange Code (via Backend): Your frontend sends the authorization code and the original code_verifier to your own backend. Your backend then makes a server-to-server call to the Authorization Server’s /token endpoint to exchange the code for access_token, refresh_token, and potentially an ID token (if using OIDC). This step is crucial for security as it prevents the client_secret from being exposed in the frontend.
  7. Tokens to Client: Your backend sends the access_token to your frontend and typically sets the refresh_token in an HttpOnly, Secure, SameSite cookie for better security.
  8. API Calls: Your frontend uses the access_token (usually in an Authorization: Bearer <token> header) to make requests to the Resource Server’s APIs.

OpenID Connect (OIDC): Authentication on Top of OAuth 2.0

If OAuth 2.0 is about authorization (what you can do), OpenID Connect (OIDC) is about authentication (who you are). OIDC is an identity layer built on top of the OAuth 2.0 framework. It uses OAuth 2.0’s flows to enable clients to verify the identity of the end-user based on the authentication performed by an Authorization Server, as well as to obtain basic profile information about the end-user.

What is OIDC?

Think of it as adding an “identity card” to the car keys analogy. With OIDC, when the valet gets the car keys, they also get a verifiable ID card that explicitly states who the car owner is.

Key Concept: The ID Token

The central piece of OIDC is the ID Token. This is a special type of JWT (which we’ll cover next) that contains claims about the authenticated user, such as their user ID, name, email, and whether their email is verified.

  • Purpose: The ID Token allows the client application to know who logged in and get basic user information without needing to query a separate user info endpoint immediately.
  • Verification: The client must verify the ID Token’s signature and claims to ensure it hasn’t been tampered with and is valid.

Essentially, OIDC provides a standardized way for your app to say, “Hey Authorization Server, tell me who this user is, and give me a verifiable proof of their identity!”

JSON Web Tokens (JWT): The Secure Information Carriers

JSON Web Tokens, pronounced “jot,” are a compact, URL-safe means of representing claims (information) to be transferred between two parties. They are widely used in conjunction with OAuth 2.0 and OIDC for both access tokens and ID tokens.

What is a JWT?

A JWT is a string that consists of three parts, separated by dots (.):

header.payload.signature

Let’s break down each part:

  1. Header:

    • This is a JSON object that typically contains two fields:
      • alg: The algorithm used to sign the token (e.g., HS256 for HMAC SHA256, RS256 for RSA SHA256).
      • typ: The type of token, which is usually JWT.
    • Example (Base64Url encoded): eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
    • Decoded: {"alg": "HS256", "typ": "JWT"}
  2. Payload (Claims):

    • This is another JSON object that contains the “claims” – statements about an entity (typically the user) and additional data.
    • There are three types of claims:
      • Registered Claims: Predefined claims like iss (issuer), exp (expiration time), sub (subject), aud (audience). These are recommended but not mandatory.
      • Public Claims: Custom claims defined by those using JWTs, but registered in the IANA JSON Web Token Registry to avoid collisions.
      • Private Claims: Custom claims created to share information between parties that agree on their use (e.g., role: "admin").
    • Crucial Security Note: The payload is encoded, not encrypted. This means anyone can easily decode the payload and read its contents. NEVER put sensitive information (like passwords, PII that shouldn’t be publicly visible) in the JWT payload.
    • Example (Base64Url encoded): eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ
    • Decoded: {"sub": "1234567890", "name": "John Doe", "iat": 1516239022}
  3. Signature:

    • This part is used to verify that the token hasn’t been tampered with and was indeed sent by the expected sender.
    • It’s created by taking the encoded header, the encoded payload, a secret (known only to the issuer and receiver), and the algorithm specified in the header, then signing them.
    • Formula: HMACSHA256(base64UrlEncode(header) + "." + base64UrlEncode(payload), secret)
    • The signature is what makes a JWT trustworthy. Without a valid signature, the token is useless and should be rejected.

Why are JWTs used?

  • Statelessness: The server doesn’t need to store session information. Each request contains the necessary authentication and authorization data within the JWT itself. This is great for scalability in microservices architectures.
  • Compact & URL-safe: Can be easily transmitted in URL parameters, POST bodies, or HTTP headers.
  • Verifiable: The signature ensures integrity; you can trust the claims inside if the signature is valid.

Security Considerations for JWTs

  • Signature Verification is a MUST: Always, always verify the signature on every incoming JWT. If the signature is invalid, the token is invalid.
  • Expiration (exp) Claim: Always check the exp claim. Expired tokens must be rejected.
  • Audience (aud) Claim: Verify that the token is intended for your application.
  • Issuer (iss) Claim: Verify that the token was issued by the expected Authorization Server.
  • Short Lifespan: Access tokens should have a short expiration time (e.g., 5-15 minutes) to limit the window of opportunity for token replay attacks if they are compromised.
  • Refresh Tokens: Use refresh tokens (stored securely, preferably in HttpOnly cookies) to obtain new, short-lived access tokens without re-authenticating the user. This balances security with user experience.
  • No Sensitive Data in Payload: As mentioned, the payload is encoded, not encrypted. Anyone can read it.
  • Revocation: JWTs are stateless, making immediate revocation difficult without a central blacklist. Short expiration times mitigate this. If a refresh token is compromised, it should be immediately revoked on the server side.

Step-by-Step Implementation: Token Handling (Conceptual)

Implementing a full OAuth 2.0/OIDC flow from scratch involves significant complexity. In real-world applications (as of 2026), you’ll typically use well-vetted libraries or SDKs provided by your identity provider (e.g., Auth0, Okta, Google Identity Services) or a general-purpose OAuth/OIDC client library.

Here, we’ll outline the conceptual steps for a React/Angular SPA, focusing on token handling best practices.

Client-Side (React/Angular) Token Handling Strategy

For a modern SPA, the recommended approach usually involves:

  • Access Token: Stored in memory (e.g., a state management solution like Redux, Zustand, NgRx) and only used for API calls. If persistence across page refreshes is absolutely required, sessionStorage or localStorage can be used, but only if robust XSS prevention measures are in place. The preferred approach is to obtain a new access token using the refresh token upon page load.
  • Refresh Token: Stored in a HttpOnly, Secure, SameSite=Lax or Strict cookie. This mitigates XSS and CSRF risks significantly. The refresh token is sent automatically by the browser with requests to your backend’s token refresh endpoint.

Let’s imagine a simplified flow in a React component:

// Conceptual React component for handling login callback
// In a real app, you'd use a robust library like 'oidc-client-ts' or a provider's SDK

import React, { useEffect, useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
// Assume an API client that handles setting Authorization header
import { apiClient } from './apiClient';

function AuthCallback() {
  const location = useLocation();
  const navigate = useNavigate();
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    const handleAuthCallback = async () => {
      const params = new URLSearchParams(location.search);
      const code = params.get('code');
      const state = params.get('state'); // For CSRF protection (should match original request state)

      // In a real app, you'd also retrieve the PKCE code_verifier from sessionStorage
      // and compare the 'state' parameter to the one stored before redirecting.

      if (code) {
        try {
          // STEP 1: Send authorization code and PKCE verifier to YOUR backend
          // YOUR backend will then exchange it for tokens with the Authorization Server
          const response = await fetch('/api/auth/token', {
            method: 'POST',
            headers: {
              'Content-Type': 'application/json',
            },
            body: JSON.stringify({
              code: code,
              // code_verifier: 'your_pkce_code_verifier_here', // Sent by backend
              // state: state // Sent by backend
            }),
          });

          if (!response.ok) {
            throw new Error('Failed to exchange code for tokens');
          }

          const data = await response.json();
          const { accessToken } = data; // Your backend sends the access token back

          // STEP 2: Store the Access Token (e.g., in memory/state for short-term use)
          // The refresh token would be set as an HttpOnly cookie by your backend.
          apiClient.setAccessToken(accessToken); // Example: store in API client for future requests
          localStorage.setItem('accessToken', accessToken); // Less secure, but common for short-lived access tokens with strong XSS prevention

          // Redirect to the dashboard or intended page
          navigate('/dashboard');

        } catch (err) {
          console.error('Authentication error:', err);
          setError('Authentication failed. Please try again.');
          setLoading(false);
        }
      } else {
        setError('No authorization code found.');
        setLoading(false);
      }
    };

    handleAuthCallback();
  }, [location, navigate]);

  if (loading) {
    return <div>Authenticating...</div>;
  }

  if (error) {
    return <div>Error: {error}</div>;
  }

  return null; // Should redirect before rendering
}

export default AuthCallback;

Explanation of the pseudo-code:

  • This component simulates the callback endpoint where the Authorization Server redirects the user after successful login.
  • It extracts the code from the URL.
  • It then makes a request to your own backend (/api/auth/token). This is a critical security step: your frontend should not directly exchange the code for tokens with the Authorization Server if a client_secret is involved. Even with PKCE, it’s often safer to proxy through your backend.
  • Your backend handles the secure server-to-server communication with the Authorization Server.
  • Upon success, your backend sends the accessToken back to the frontend and sets the refreshToken as an HttpOnly cookie.
  • The accessToken is then stored (e.g., in localStorage for quick retrieval, though memory storage is preferred if localStorage XSS risk is too high) and used for subsequent API calls.

Backend (Node.js/Python/Go) JWT Verification (Conceptual)

When your backend API receives a request with a JWT in the Authorization: Bearer <token> header, it needs to verify that token before processing the request.

// Conceptual Node.js (Express) middleware for JWT verification
// In a real app, you'd use a library like 'jsonwebtoken' or 'express-oauth2-jwt-bearer'

const jwt = require('jsonwebtoken'); // npm install jsonwebtoken
const jwksClient = require('jwks-rsa'); // npm install jwks-rsa (for RS256 keys)

// Configuration for your Authorization Server's public keys
// This URL provides the public keys needed to verify JWTs signed by the Auth Server
const jwksUri = 'https://YOUR_AUTH_SERVER/.well-known/jwks.json';
const issuer = 'https://YOUR_AUTH_SERVER/'; // The 'iss' claim in the JWT
const audience = 'YOUR_API_IDENTIFIER'; // The 'aud' claim in the JWT

// Create a JWKS client to fetch public keys
const client = jwksClient({
  jwksUri: jwksUri,
  cache: true, // Cache the signing keys to avoid repeated requests
  rateLimit: true,
  jwksRequestsPerMinute: 10, // Prevent abuse
});

// Function to get the signing key from the JWKS client
function getKey(header, callback) {
  client.getSigningKey(header.kid, (err, key) => {
    const signingKey = key.publicKey || key.rsaPublicKey;
    callback(null, signingKey);
  });
}

const verifyJwtMiddleware = (req, res, next) => {
  const authHeader = req.headers.authorization;

  if (!authHeader || !authHeader.startsWith('Bearer ')) {
    return res.status(401).json({ message: 'No token provided' });
  }

  const token = authHeader.split(' ')[1];

  jwt.verify(token, getKey, {
    audience: audience,
    issuer: issuer,
    algorithms: ['RS256'], // Specify expected algorithms
    // other verification options like maxAge, clockTolerance, etc.
  }, (err, decoded) => {
    if (err) {
      console.error('JWT Verification Error:', err);
      // Handle specific errors like TokenExpiredError, JsonWebTokenError
      return res.status(403).json({ message: 'Invalid or expired token' });
    }

    // Token is valid! Attach decoded payload to request for downstream use
    req.user = decoded;
    next();
  });
};

// Example usage in an Express route
// app.get('/protected-data', verifyJwtMiddleware, (req, res) => {
//   res.json({ message: `Welcome, ${req.user.name || req.user.sub}! This is protected data.` });
// });

Explanation of the pseudo-code:

  • This verifyJwtMiddleware function would be applied to protected API routes.
  • It extracts the JWT from the Authorization header.
  • It then uses a library (jsonwebtoken) to verify the token.
  • Crucially, it uses jwks-rsa to dynamically fetch the public keys from the Authorization Server’s JWKS (JSON Web Key Set) endpoint (.well-known/jwks.json). This is the modern and secure way to verify tokens signed with asymmetric algorithms (like RS256) without hardcoding keys.
  • The jwt.verify function automatically checks the signature, expiration (exp), audience (aud), and issuer (iss) claims.
  • If verification passes, the decoded payload (req.user) is attached to the request, allowing your API logic to use the user’s information.
  • If verification fails, an appropriate error response is sent.

Mini-Challenge: Orchestrating the GitHub Login

Let’s put your understanding of the Authorization Code Flow with PKCE into a practical scenario.

Challenge:

Imagine you’re building a sleek new portfolio website with React (your client-side application) that wants to display a user’s latest GitHub repositories after they’ve logged in and granted permission via GitHub.

Describe, in plain English or pseudo-code, the high-level steps your React app (and your optional backend) would take to implement the Authorization Code Flow with PKCE using GitHub as the Authorization Server and Resource Server. Focus on the sequence of events and the key information exchanged.

Hint: Think about the redirects, the parameters in the URLs, and which entity (your frontend, your backend, GitHub’s auth server, GitHub’s API) is responsible for what action.

What to observe/learn: This exercise should solidify your grasp of the roles involved, the flow of tokens, and the importance of PKCE in securing public clients.

Common Pitfalls & Troubleshooting

Even with robust protocols, misconfigurations or misunderstandings can lead to vulnerabilities.

  1. Storing Tokens Insecurely (especially Refresh Tokens):

    • Pitfall: Storing refresh_tokens directly in localStorage or sessionStorage. These are vulnerable to Cross-Site Scripting (XSS) attacks. If an attacker can inject malicious JavaScript, they can steal your long-lived refresh tokens, granting them persistent access.
    • Best Practice: Store refresh_tokens in HttpOnly, Secure, SameSite=Lax or Strict cookies. HttpOnly prevents JavaScript access, Secure ensures transmission over HTTPS only, and SameSite mitigates CSRF. Access tokens (short-lived) can be stored in memory or localStorage if XSS is rigorously prevented and their short lifespan limits exposure.
    • Troubleshooting: If tokens are being stolen or replayed, check your storage mechanisms first.
  2. Not Validating JWT Signatures:

    • Pitfall: Accepting JWTs without verifying their signature, or using an insecure signature algorithm (e.g., alg: "none"). An attacker could forge a token with arbitrary claims.
    • Best Practice: Always verify the JWT signature using the correct public key (for RS256) or shared secret (for HS256). Always specify the allowed algorithms in your verification library.
    • Troubleshooting: If unauthorized users are gaining access, check your backend JWT verification logic.
  3. Using Deprecated OAuth Flows:

    • Pitfall: Implementing the Implicit Flow (response_type=token) for SPAs. This flow directly returns the access token in the URL fragment, making it vulnerable to various interception attacks.
    • Best Practice: As of 2026, always use the Authorization Code Flow with PKCE for public clients like SPAs and mobile apps.
    • Troubleshooting: If you find access tokens in browser history or referrer headers, you’re likely using an insecure flow. Migrate to Authorization Code with PKCE.
  4. Forgetting PKCE for Public Clients:

    • Pitfall: Using the Authorization Code Flow for SPAs without PKCE. Without PKCE, if a malicious app intercepts the authorization code, it can exchange it for tokens.
    • Best Practice: PKCE adds a “proof of possession” to the authorization code, ensuring only the original client can redeem it. Always implement PKCE for public clients.
    • Troubleshooting: If an authorization code is stolen and exchanged by an unintended client, PKCE was likely missing or misconfigured.
  5. Too Long JWT Expiry or No Refresh Token Rotation:

    • Pitfall: Setting JWT access tokens to expire in hours or days, or not rotating refresh tokens. A compromised long-lived token grants an attacker prolonged access.
    • Best Practice: Access tokens should be short-lived (e.g., 5-15 minutes). Use refresh tokens to obtain new access tokens. Implement refresh token rotation, where each time a refresh token is used, a new one is issued, and the old one is invalidated.
    • Troubleshooting: If a user’s session remains active for too long after suspected compromise, review token expiry and refresh token strategies.

Summary

Phew! We’ve covered a lot of ground in this chapter, laying the foundation for secure identity management in modern web applications. Here’s a quick recap of the key takeaways:

  • Authentication (AuthN) verifies who you are, while Authorization (AuthZ) determines what you can do.
  • OAuth 2.0 is an authorization framework for delegated access, allowing third-party apps to access resources without sharing user credentials. It’s not for authentication.
  • The Authorization Code Flow with PKCE is the recommended and most secure OAuth 2.0 flow for public clients (SPAs, mobile apps) as of 2026.
  • OpenID Connect (OIDC) is an identity layer built on OAuth 2.0, providing a standardized way to verify user identity and obtain basic profile information using ID Tokens.
  • JSON Web Tokens (JWTs) are compact, URL-safe carriers of signed information (claims). They are widely used for access_tokens and ID tokens.
  • JWTs are encoded, not encrypted. Never put sensitive information in the payload. Their security relies on signature verification.
  • Secure token storage is crucial: refresh_tokens should be in HttpOnly, Secure, SameSite cookies, while access_tokens are best kept in memory or localStorage with strong XSS prevention.
  • Always verify JWT signatures, check exp, aud, and iss claims, and use short-lived access tokens combined with refresh tokens.

By understanding and correctly implementing these protocols, you can build applications that securely handle user identity and access, protecting both your users and your data.

In the next chapters, we’ll continue exploring specific security practices within modern frontend frameworks and dive deeper into API security. Stay vigilant and keep learning!

References


This page is AI-assisted and reviewed. It references official documentation and recognized resources where relevant.