Skip to main content
the auth layer

The Authorization Code Flow at the Packet Level

6 min read Chapter 8 of 45

The Authorization Code Flow at the Packet Level

The Assumption

Most OAuth2 implementations treat the authorization code flow as a redirect dance with a token at the end. The assumption: if you follow the tutorial’s redirect pattern, the flow is secure.

The flow has seven distinct security parameters. Most tutorials explain two of them. The other five prevent specific, named attacks that work against implementations that omit them.

The Attack

Authorization code interception without PKCE. A mobile application uses a custom URI scheme (myapp://callback) as its redirect URI. The attacker installs a malicious app on the same device that registers the same custom URI scheme. When the authorization server redirects with the authorization code, the OS may route the redirect to the malicious app instead of (or in addition to) the legitimate app. The malicious app now has the authorization code.

Without PKCE, the malicious app can exchange this code for tokens at the token endpoint. The authorization server has no way to distinguish the legitimate client from the attacker because both present the same client_id and the code is the only proof of authorization.

With PKCE, the legitimate client generated a random code_verifier before starting the flow and sent a code_challenge (SHA-256 hash of the verifier) in the authorization request. At the token endpoint, the client must present the original code_verifier. The malicious app intercepted the code but never saw the code_verifier (it was never transmitted over a redirectable channel). The token exchange fails.

CSRF on the authorization callback. The attacker initiates an authorization flow, receives an authorization code, but does not exchange it. Instead, the attacker crafts a URL: https://victim-app.com/callback?code=ATTACKERS_CODE. The attacker tricks the victim into visiting this URL (via an image tag, a link, or a redirect). The victim’s browser hits the callback endpoint with the attacker’s authorization code. The victim’s application exchanges the code for tokens and associates them with the victim’s session. The victim is now logged in as the attacker. Any data the victim enters goes into the attacker’s account.

The state parameter prevents this. The client generates a random state value, stores it in the user’s session, and sends it in the authorization request. The authorization server returns it unchanged in the callback. The client verifies that the returned state matches the stored value. The attacker cannot predict or set the victim’s state value, so the forged callback is rejected.

The Spec or Mechanism

RFC 6749 Section 4.1 defines the authorization code grant. RFC 7636 defines PKCE. The flow proceeds in five HTTP exchanges:

1. Authorization Request (Browser → Authorization Server)

GET /oauth2/authorize?
  response_type=code&
  client_id=frontend-shell&
  redirect_uri=https://app.saas.example/callback&
  scope=openid profile&
  state=xyzABC123&
  nonce=n-0S6_WzA2Mj&
  code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM&
  code_challenge_method=S256
HTTP/1.1
Host: auth.saas.example

Parameters and what each prevents:

  • response_type=code: Requests an authorization code, not a token directly (which would be the implicit flow).
  • client_id: Identifies the requesting application. Not a secret.
  • redirect_uri: Exact URI where the authorization server sends the response. Must match a registered URI exactly. Prevents open redirect attacks.
  • scope: Requested permissions. The authorization server may grant fewer scopes than requested.
  • state: CSRF prevention. Random value bound to the user’s browser session.
  • nonce: Replay prevention for the ID token. Included in the ID token claims; client verifies it matches.
  • code_challenge: PKCE. SHA-256 hash of the code_verifier. Prevents code interception attacks.
  • code_challenge_method=S256: Declares the hash method. Never use plain (which provides no security).

The authorization server authenticates the user (login form, SSO, MFA) and asks for consent to grant the requested scopes to the client. This step is internal to the authorization server and not standardized beyond the requirement that the user must authenticate.

3. Authorization Response (Authorization Server → Browser → Client)

HTTP/1.1 302 Found
Location: https://app.saas.example/callback?
  code=SplxlOBeZQQYbYS6WxSbIA&
  state=xyzABC123

The authorization server issues a short-lived, single-use authorization code and redirects the browser to the registered redirect_uri with the code and the original state.

4. Token Request (Client → Authorization Server, back-channel)

POST /oauth2/token HTTP/1.1
Host: auth.saas.example
Content-Type: application/x-www-form-urlencoded
Authorization: Basic base64(client_id:client_secret)

grant_type=authorization_code&
code=SplxlOBeZQQYbYS6WxSbIA&
redirect_uri=https://app.saas.example/callback&
code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk

This is a back-channel request (server-to-server, not through the browser). The client authenticates with its credentials and presents:

  • The authorization code
  • The same redirect_uri (the authorization server verifies it matches the one from step 1)
  • The code_verifier (the authorization server hashes it with S256 and verifies it matches the code_challenge from step 1)

5. Token Response (Authorization Server → Client)

{
  "access_token": "eyJhbGciOiJSUzI1NiIs...",
  "token_type": "Bearer",
  "expires_in": 300,
  "refresh_token": "tGzv3JOkF0XG5Qx2TlKWIA",
  "id_token": "eyJhbGciOiJSUzI1NiIs...",
  "scope": "openid profile"
}

The client receives the access token (for API calls), refresh token (for obtaining new access tokens), and ID token (for authentication identity).

The Implementation

// Spring Authorization Server: Authorization endpoint configuration
@Bean
public RegisteredClientRepository registeredClientRepository() {
    RegisteredClient frontendShell = RegisteredClient.withId(UUID.randomUUID().toString())
        .clientId("frontend-shell")
        .clientSecret("{bcrypt}" + bcryptEncoder.encode("shell-secret-value"))
        .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
        .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
        .authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)
        .redirectUri("https://app.saas.example/callback")
        .scope(OidcScopes.OPENID)
        .scope(OidcScopes.PROFILE)
        .scope("tenant:read")
        .clientSettings(ClientSettings.builder()
            .requireProofKey(true) // PKCE mandatory
            .requireAuthorizationConsent(true)
            .build())
        .tokenSettings(TokenSettings.builder()
            .accessTokenTimeToLive(Duration.ofMinutes(5))
            .refreshTokenTimeToLive(Duration.ofHours(8))
            .reuseRefreshTokens(false) // Rotation enabled
            .build())
        .build();

    return new InMemoryRegisteredClientRepository(frontendShell);
}
// VULNERABLE: Redirect URI with wildcard matching
RegisteredClient vulnerable = RegisteredClient.withId(UUID.randomUUID().toString())
    .clientId("bad-client")
    .redirectUri("https://app.saas.example/*") // NEVER: wildcard allows open redirect
    .redirectUri("https://app.saas.example/callback?next=") // NEVER: parameter allows injection
    .clientSettings(ClientSettings.builder()
        .requireProofKey(false) // NEVER: code interception possible
        .build())
    .build();
// HARDENED: Exact redirect URI, PKCE required
RegisteredClient hardened = RegisteredClient.withId(UUID.randomUUID().toString())
    .clientId("frontend-shell")
    .redirectUri("https://app.saas.example/callback") // Exact match only
    .clientSettings(ClientSettings.builder()
        .requireProofKey(true)  // PKCE mandatory for all clients
        .requireAuthorizationConsent(true) // User must explicitly consent
        .build())
    .build();

The Verification

# Step 1: Generate PKCE values
CODE_VERIFIER=$(openssl rand -base64 32 | tr -d '=' | tr '/+' '_-')
CODE_CHALLENGE=$(echo -n "$CODE_VERIFIER" | openssl dgst -sha256 -binary | base64 | tr -d '=' | tr '/+' '_-')

# Step 2: Initiate authorization (open in browser)
echo "https://auth.saas.example/oauth2/authorize?\
response_type=code&\
client_id=frontend-shell&\
redirect_uri=https://app.saas.example/callback&\
scope=openid%20profile&\
state=random-state-value&\
code_challenge=$CODE_CHALLENGE&\
code_challenge_method=S256"

# Step 3: After authentication, extract code from callback URL
# Callback: https://app.saas.example/callback?code=AUTH_CODE&state=random-state-value
AUTH_CODE="<extracted-from-callback>"

# Step 4: Exchange code for tokens
curl -X POST https://auth.saas.example/oauth2/token \
  -u "frontend-shell:shell-secret-value" \
  -d "grant_type=authorization_code" \
  -d "code=$AUTH_CODE" \
  -d "redirect_uri=https://app.saas.example/callback" \
  -d "code_verifier=$CODE_VERIFIER"

# Step 5: Verify that wrong code_verifier fails
curl -X POST https://auth.saas.example/oauth2/token \
  -u "frontend-shell:shell-secret-value" \
  -d "grant_type=authorization_code" \
  -d "code=$AUTH_CODE" \
  -d "redirect_uri=https://app.saas.example/callback" \
  -d "code_verifier=wrong-verifier-value"
# Expected: 400 Bad Request, {"error": "invalid_grant"}
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class AuthorizationCodeFlowTest {

    @Autowired
    private WebTestClient webTestClient;

    @Test
    void authorizationRequestWithoutPKCEIsRejected() {
        webTestClient.get()
            .uri(uriBuilder -> uriBuilder
                .path("/oauth2/authorize")
                .queryParam("response_type", "code")
                .queryParam("client_id", "frontend-shell")
                .queryParam("redirect_uri", "https://app.saas.example/callback")
                .queryParam("scope", "openid")
                .queryParam("state", "test-state")
                // No code_challenge parameter
                .build())
            .exchange()
            .expectStatus().isBadRequest();
    }

    @Test
    void unregisteredRedirectUriIsRejected() {
        webTestClient.get()
            .uri(uriBuilder -> uriBuilder
                .path("/oauth2/authorize")
                .queryParam("response_type", "code")
                .queryParam("client_id", "frontend-shell")
                .queryParam("redirect_uri", "https://attacker.example/steal")
                .queryParam("scope", "openid")
                .queryParam("state", "test-state")
                .queryParam("code_challenge", "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM")
                .queryParam("code_challenge_method", "S256")
                .build())
            .exchange()
            .expectStatus().isBadRequest();
    }
}

The first test proves that the authorization server rejects requests without PKCE when requireProofKey(true) is configured. The second test proves that redirect URI validation rejects URIs not registered for the client. Both are security controls that fail open in many default configurations.