← System Design Simulator

OAuth 2.0 Flow

By Rahul Kumar · Senior Software Engineer · Updated · Category: System Design Primer · Unique Topics

Authorization code + PKCE; attack scenarios fail when PKCE is on.

This interactive explanation is built for system design interview prep: step through OAuth 2.0 Flow, watch the internal state change, and connect the concept to real distributed-system trade-offs.

Overview

OAuth 2.0 is not an authentication protocol. It is a delegated-authorization protocol that lets an app act on behalf of a user without ever seeing the user's password. The most common flow, Authorization Code with PKCE (Proof Key for Code Exchange), is the one every mobile and single-page app should use today — it replaces the old implicit flow and closes the authorization-code interception attack that mobile apps were vulnerable to. The flow has four parties: the resource owner (the user), the client (your app), the authorization server (the identity provider, e.g. Google, Auth0, Okta), and the resource server (the API the client wants to call). The client redirects the user to the auth server, the user authenticates and consents, the auth server hands the client an opaque code, and the client exchanges the code for an access token. PKCE binds the exchange to a secret the attacker cannot forge, so stealing the code does not yield the token.

OAuth 2.0 Flow — Interactive Simulator

Runs fully client-side in your browser; no sign-up. Or open full screen →

Launch the interactive OAuth 2.0 Flow widget — step through the algorithm or protocol and observe the internal state updating in real time.

How it works

The PKCE flow starts before the user is even redirected. The client generates a random 32-64 byte code_verifier, then computes code_challenge = BASE64URL(SHA256(code_verifier)). The client opens the browser to the authorization URL with response_type=code, client_id, redirect_uri, scope, state (CSRF token), code_challenge, and code_challenge_method=S256. The user logs in on the authorization server; the server validates the redirect_uri against the registered list and shows a consent screen. On consent, the server redirects the browser back to redirect_uri with code and state. The client verifies the state matches what it stored, then POSTs the code, code_verifier, client_id, and redirect_uri to the token endpoint. The server hashes the code_verifier, compares to the stored code_challenge, and if they match issues an access_token (short-lived, 15-60 min) and a refresh_token (long-lived, rotated on use). The client calls the resource server with Authorization: Bearer <access_token>. The resource server validates the token — either by introspection or by verifying its JWT signature — and serves the API. When the access token expires, the client uses the refresh token to get a new one without prompting the user again. Refresh token rotation detects theft: if the same refresh token is used twice, both the new and old sessions are invalidated. Scopes enforce least privilege: a token with scope=read:profile cannot write email. State binds the redirect to the original session and prevents CSRF during the callback. Nonce (OpenID Connect) binds the id_token to the session to prevent replay.

Implementation

AuthorizationCodeFlow with PKCE: verifier, challenge, token exchange
public class AuthorizationCodeFlow {
    private static final SecureRandom RNG = new SecureRandom();
    private final String clientId;
    private final String redirectUri;
    private final URI authEndpoint;
    private final URI tokenEndpoint;
    private final HttpClient http = HttpClient.newHttpClient();

    public AuthorizationCodeFlow(String clientId, String redirectUri,
                                 URI authEndpoint, URI tokenEndpoint) {
        this.clientId = clientId; this.redirectUri = redirectUri;
        this.authEndpoint = authEndpoint; this.tokenEndpoint = tokenEndpoint;
    }

    /** Generate a 64-byte URL-safe verifier; keep it in session. */
    public String newCodeVerifier() {
        byte[] b = new byte[64];
        RNG.nextBytes(b);
        return Base64.getUrlEncoder().withoutPadding().encodeToString(b);
    }

    /** S256 challenge = base64url(sha256(verifier)). */
    public String codeChallenge(String verifier) throws Exception {
        byte[] h = MessageDigest.getInstance("SHA-256")
                                .digest(verifier.getBytes(StandardCharsets.US_ASCII));
        return Base64.getUrlEncoder().withoutPadding().encodeToString(h);
    }

    /** Build the authorize URL to open in the user's browser. */
    public URI authorizeUrl(String state, String challenge, String scope) {
        return URI.create(authEndpoint + "?response_type=code"
            + "&client_id=" + URLEncoder.encode(clientId, StandardCharsets.UTF_8)
            + "&redirect_uri=" + URLEncoder.encode(redirectUri, StandardCharsets.UTF_8)
            + "&scope=" + URLEncoder.encode(scope, StandardCharsets.UTF_8)
            + "&state=" + state
            + "&code_challenge=" + challenge
            + "&code_challenge_method=S256");
    }

    /** Exchange the code (plus the original verifier) for tokens. */
    public TokenResponse exchange(String code, String verifier) throws Exception {
        String body = "grant_type=authorization_code"
            + "&code=" + URLEncoder.encode(code, StandardCharsets.UTF_8)
            + "&redirect_uri=" + URLEncoder.encode(redirectUri, StandardCharsets.UTF_8)
            + "&client_id=" + URLEncoder.encode(clientId, StandardCharsets.UTF_8)
            + "&code_verifier=" + URLEncoder.encode(verifier, StandardCharsets.UTF_8);
        HttpRequest req = HttpRequest.newBuilder(tokenEndpoint)
            .header("Content-Type", "application/x-www-form-urlencoded")
            .POST(HttpRequest.BodyPublishers.ofString(body)).build();
        HttpResponse<String> resp = http.send(req, HttpResponse.BodyHandlers.ofString());
        if (resp.statusCode() != 200) throw new IOException("token endpoint: " + resp.statusCode());
        return TokenResponse.parse(resp.body());
    }

    public record TokenResponse(String accessToken, String refreshToken, long expiresIn) {
        static TokenResponse parse(String json) { /* parse JSON */ return null; }
    }
}

Complexity

Key design decisions & trade-offs

Common pitfalls

Interview follow-ups

Recommended reading

Related