arthrop0d

Brief articles on varied topics.

OAuth 2.0 PKCE and Authorization Code Binding

computer securityweb development

OAuth 2.0 PKCE (Proof Key for Code Exchange) binds an authorization code to the client request that started the authorization flow. The client creates a secret code verifier, sends only a derived code challenge during authorization, and later proves possession of the verifier when exchanging the code for tokens.

The problem PKCE solves

In the OAuth 2.0 authorization code flow, a client sends the user to an authorization server—the service that authenticates the user and issues tokens. After the user approves access, the authorization server redirects the browser to the client with a short-lived authorization code. The client then sends that code to the authorization server’s token endpoint to receive an access token.

That code is not supposed to be useful by itself for long. However, the redirect travels through a user agent such as a browser, and mobile operating systems may allow multiple applications to register the same custom URL scheme. A malicious application can register the same redirect scheme as the legitimate application and receive the authorization response first.

This is the authorization-code interception attack:

  1. The legitimate application starts an OAuth login.
  2. The user authenticates and approves access.
  3. The authorization server redirects the browser with an authorization code.
  4. A malicious application intercepts that redirect and copies the code.
  5. The malicious application sends the code to the token endpoint before the legitimate application does.

Without an additional proof, the token endpoint may have no way to tell that the request came from the wrong application. The attacker has the code, and a public client such as a mobile app or single-page application cannot safely keep a client secret.

The verifier and challenge

PKCE adds a one-time secret to this flow. Before opening the authorization page, the client generates a high-entropy random string called the code verifier. It keeps this value locally; it does not put the verifier in the browser redirect or authorization URL.

The client derives a code challenge from the verifier. The recommended method, S256, is:

code_challenge = BASE64URL( SHA256(code_verifier) )

BASE64URL means URL-safe Base64 encoding with trailing = padding removed. The client sends the challenge, along with the usual authorization parameters, to the authorization endpoint:

response_type=code
client_id=mobile-app
redirect_uri=com.example.app:/oauth/callback
code_challenge=the-derived-value
code_challenge_method=S256

The challenge is safe to expose in the authorization URL. It is a one-way transformation of the verifier, so knowing the challenge should not let an attacker reconstruct the verifier.

The authorization server records the challenge with the authorization code it creates. Conceptually, the code now means: “this code may be exchanged only by whoever can provide the verifier that produced this challenge.”

The token exchange

After the redirect, the legitimate client receives the authorization code. It retrieves the verifier it saved earlier and sends both values to the token endpoint:

grant_type=authorization_code
code=the-authorization-code
redirect_uri=com.example.app:/oauth/callback
client_id=mobile-app
code_verifier=the-original-random-value

The authorization server performs the same derivation:

BASE64URL( SHA256(code_verifier) )

It compares the result with the challenge stored alongside the authorization code. If they match, the server continues the normal checks—such as whether the code is valid, unused, unexpired, and associated with the same redirect URI—and issues tokens. If they do not match, the exchange fails.

The code is normally single-use, so a failed or successful exchange cannot simply be replayed later.

Why interception no longer works

Suppose the malicious application intercepts the redirect. It obtains the authorization code, but it does not obtain the legitimate application’s verifier. When it sends the code to the token endpoint, it either omits code_verifier or supplies its own value. The resulting challenge does not match the one stored with the code, so the server rejects the exchange.

The legitimate application can still redeem the code because it retained the original verifier. PKCE therefore binds the code to the particular authorization request that created it—not merely to a client_id that another application might copy.

PKCE does not make the authorization code secret. It makes the code insufficient on its own.

What PKCE does and does not protect

PKCE is especially important for public clients: clients that cannot safely store a long-term secret, including native mobile applications, desktop applications, and browser-based applications. It is also commonly used by confidential clients as defense in depth.

PKCE does not replace the state parameter. state links the authorization response to the browser session that initiated it and helps prevent request-forgery attacks. PKCE protects the code exchange by requiring proof of the verifier. A robust implementation generally uses both, along with an exact redirect URI and HTTPS where applicable.

PKCE also does not protect a client whose verifier has been stolen—for example, by malicious code running inside the same browser origin or a compromised device. Its specific job is narrower: to stop someone who obtains the authorization code through redirect interception from exchanging that code for tokens.

If you have seen an OAuth error such as invalid_grant after adding a code_challenge, a missing, mismatched, reused, or incorrectly encoded code_verifier is a common cause. The verifier must be generated for that authorization attempt, preserved until the token exchange, and sent exactly as generated.

← All articles