Keeping the token on the server and giving the browser only a short-lived code shrinks the surface for token theft in the URL or the log.

The browser gets a code, not a token

The browser is deliberately the wrong place for the access token. In the authorization-code flow the browser goes to the authorization server, sees the consent screen, and comes back at the redirect URI carrying only a short authorization code, never the token. The token stays off the browser’s URL history and out of its logs, because it is the backend that presents the code together with the client secret to the token endpoint. That split of labour — the browser carries the code, the server owns the secret — is the entire point of the flow.

The backend does the exchange

The exchange happens server-side, so the access token is minted and kept where the browser’s history cannot log it and a client-side bug cannot leak it. The backend sends the code, its client secret, and its redirect_uri to the token endpoint, getting a JSON access token (and optionally a refresh token) that never passed through the browser. This is what makes public clients (a native or SPA) a liability in this flow: they cannot hold a client secret, so the code flow is the right choice only for confidential clients. The trade the code flow makes is a server round-trip in exchange for never exposing the token to the user agent.

Code is one-shot and short-lived

A correct code is single-use and short-lived, so it removes replay and live-window risk at once. If the code is intercepted, it cannot be reused for a second exchange; if it sits unused, it expires on its own within its short lifetime. Two protections make that airtight: bind the code to the exact redirect_uri that was used in the original request (PKCE adds a verifier so an intercepted code is worthless without the client-held code_verifier), and store codes with an expiry. Code is the currency that trades once — treat its one-shotness as a contract, not guidance.

When it breaks

A leaked code works like a short-lived password. Codes show up in access logs, history, Referer headers, or crash reports when the full callback URL is logged or served over plain HTTP. Reusable or long-lived codes let anyone who copies the string exchange it first. Keep codes out of logs, serve redirects over TLS, expire them in minutes, and burn each code on first use.

Loose redirect_uri checking delivers codes to attackers. A server that accepts prefix matches or unlisted hosts hands the victim’s code to a lookalike domain. Pre-register every redirect URI, compare the authorize and token values with exact equality, and reject the exchange on any mismatch.

Missing PKCE exposes public clients. A mobile or single-page app has no client secret, so an intercepted code alone suffices when no code_challenge was required. The sign is an authorize request without a challenge, or a server that accepts plain instead of S256. Send only the SHA-256 challenge and reject verifiers that do not reproduce it.

Static refresh tokens prolong the breach. Without rotation, one stolen token mints access tokens indefinitely, and without reuse detection parallel uses both succeed. Rotate the refresh token on every exchange, chain each generation to its predecessor, and revoke the family when a used token reappears.

Check it quickly

Save the following as oauth_code_demo.py and run python3 oauth_code_demo.py. It models one authorization server in memory with only hashlib, secrets, and base64, so nothing leaves the machine and no network account is needed. The script mints one code bound to a redirect URI and an S256 challenge, then exercises the exchange.

import base64
import hashlib
import secrets


def make_challenge(verifier: str) -> str:
    digest = hashlib.sha256(verifier.encode("utf-8")).digest()
    return base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii")


CODES: dict[str, dict] = {}


def authorize(code: str, challenge: str, redirect_uri: str) -> None:
    CODES[code] = {"challenge": challenge, "redirect_uri": redirect_uri, "used": False}


def exchange(code: str, verifier: str, redirect_uri: str) -> str:
    entry = CODES.get(code)
    if entry is None:
        raise AssertionError("unknown code")
    if entry["used"]:
        raise ValueError("code already used")
    if redirect_uri != entry["redirect_uri"]:
        raise ValueError("redirect_uri mismatch")
    if make_challenge(verifier) != entry["challenge"]:
        raise ValueError("PKCE verifier mismatch")
    entry["used"] = True
    return "access-token-" + secrets.token_hex(4)


verifier = secrets.token_urlsafe(32)
challenge = make_challenge(verifier)
redirect = "https://app.example/callback"
authorize("code-one", challenge, redirect)
token = exchange("code-one", verifier, redirect)
assert token.startswith("access-token-")

try:
    exchange("code-one", verifier, redirect)
    raise AssertionError("replay should have been rejected")
except ValueError as exc:
    assert str(exc) == "code already used"

authorize("code-two", challenge, redirect)
try:
    exchange("code-two", "wrong-verifier", redirect)
    raise AssertionError("wrong verifier should have been rejected")
except ValueError as exc:
    assert str(exc) == "PKCE verifier mismatch"

try:
    exchange("code-two", verifier, "https://evil.example/callback")
    raise AssertionError("wrong redirect should have been rejected")
except ValueError as exc:
    assert str(exc) == "redirect_uri mismatch"

print("PASS: one-time code, S256 PKCE, and exact redirect_uri all enforced; replay refused")

The first exchange with the correct verifier and exact redirect URI succeeds and returns a token. Replaying the same code fails because the server marks it used, and exchanging with the wrong verifier or a mismatched redirect URI also fails. The assertions encode exactly that contract, and the final line prints PASS. The demo omits client authentication, expiry clocks, and persistent storage, which a production review must still verify.

Further reading

Sources