4. Protect the OAuth handoff with state and PKCE

In step one of the OAuth flow, your app creates two temporary safety values before sending the browser to GitHub: a random state value and a PKCE code_verifier. You will implement the code in the 1_authorization_url.py file on the next page, but first let's understand what they do and why they are important.

The state parameter

The state parameter prevents a class of attack called Cross-Site Request Forgery, usually shortened to CSRF. In an OAuth flow, the danger is a callback that reaches your app but does not belong to the authorization request your app actually started.

This attack pattern matters most for hosted web apps, where a logged-in user's browser session can be steered into a forged callback. The same hygiene still belongs in this local CLI flow: your app should only trust a callback that matches the OAuth request it started.

state fixes that with an unpredictable marker tied to this OAuth attempt. Before sending the browser to GitHub, your app generates a random string and remembers it for this flow. That value travels to GitHub as the state parameter in the authorization URL. GitHub includes the same value when it redirects the browser back, but your app is responsible for checking it.

State generation
import secrets

state = secrets.token_urlsafe(32)
# Your app remembers this value until the callback returns.

The storage detail changes by app. A web application might save state in a server-side session. The split CLI scripts in this chapter print it and read it back in. The finished CLI tool keeps it in memory. In every version, the rule is the same: compare the returned value against the value your app generated before the redirect.

When the callback arrives, your app checks the returned state. If it matches the value your app saved for this flow, the callback belongs to the OAuth attempt your app started, and you can proceed to token exchange. If it does not match, or your app has no saved state for this flow, you reject the callback without exchanging the code.

Walk back through the attack scenario to see why this works. The attacker's authorization URL carries the attacker's state, but the victim's app remembers either nothing or a state from the victim's own flow. The comparison fails, and the callback is rejected before any token exchange happens.

State only works if you check it

Generating state is not enough. The callback handler must compare the returned value against the state your app remembered for this flow. If your app has no saved state, or the values do not match, reject the request before doing anything else.

Next, we'll see how a PKCE code_verifier protects the token exchange if an authorization code is intercepted.

PKCE

PKCE, short for Proof Key for Code Exchange, prevents an intercepted authorization code from being useful on its own. The danger is the redirect: the code lands on your callback URL as a normal HTTP request, where another app on the device, a browser extension, or a stray log line could capture it.

Without PKCE, the client secret is the only thing standing between a captured code and an access token. That works when the secret is well-protected, but client types that cannot keep a secret, such as mobile apps and single-page web apps, get no protection from it. Even apps that handle their secret correctly benefit from a second binding the attacker has no way to forge.

PKCE fixes that with a one-time proof tied to this OAuth attempt. Before sending the browser to GitHub, your app creates a private value called a code_verifier.

The verifier is just a long, random string. Your app keeps it private and remembers it until the token exchange.

Your app does not send the verifier to GitHub yet. Instead, it creates a public version of it called a code_challenge. The challenge is made by hashing the verifier and encoding the result in a URL-safe format.

The challenge travels to GitHub in the authorization URL. The original verifier stays with your app.

PKCE verifier and challenge
import secrets
import hashlib
import base64

# Your app remembers the verifier until the token exchange.
code_verifier = secrets.token_urlsafe(64)

digest = hashlib.sha256(code_verifier.encode("ascii")).digest()
code_challenge = base64.urlsafe_b64encode(digest).decode("ascii").rstrip("=")

PKCE in this chapter does not replace the GitHub OAuth App's client secret. The client secret still identifies your registered app at the token endpoint. PKCE adds a per-flow proof on top: the token request must carry the same verifier that produced the challenge in the authorization URL.

GitHub records the challenge when the browser arrives at the authorization URL. After the user approves, GitHub redirects back to your callback with a short-lived authorization code, just as it would without PKCE. When your app exchanges that code for an access token, it sends the original verifier along with the code. GitHub hashes the verifier and compares the result to the challenge it recorded earlier. If they match, the token endpoint issues the access token. If they do not, the exchange fails.

Walk through the interception scenario to see why this works. An attacker who captures the code from the redirect URL has only the code. The verifier never travelled through the browser, so the attacker cannot produce it. Whatever value they send to the token endpoint, or no value at all, will not hash to the challenge GitHub recorded, and the exchange is rejected.

PKCE only works if the verifier stays private

PKCE only protects the exchange while the code_verifier stays inside your app. The whole point is that the verifier never appears in the authorization URL, the redirect, the browser, or any log an attacker could read. If the verifier leaks, PKCE provides no protection.

What to remember

  • state protects your app from trusting a callback it did not start.
  • PKCE protects the token exchange if an authorization code is intercepted.
  • Both values are generated before the redirect and checked later in the flow.
  • Both values belong to one OAuth attempt. Starting a new attempt means generating new values.

Now that those safety values have names and jobs, the next page builds the authorization URL that sends the public pieces to GitHub.