Authorization-code flow moves access-token delivery to the token endpoint rather than returning an access token in the browser-facing authorization redirect. That distinction reduces exposure in URLs, but does not mean a browser can never hold a token. The client architecture determines who exchanges the code and where the resulting credentials live.

The client generates a transaction verifier and sends its S256 challenge through browser authorization, then receives a redirected code. Token exchange sends code and verifier; server checks client, registered callback, PKCE, expiry and unused state. Client architecture determines token storage. The offline sequential fixture omits full provider integration.
The authorization redirect carries a code; the token endpoint delivers tokens. PKCE, client and callback binding, expiry, and single use protect different boundaries. Storage follows the client architecture, and the offline model does not validate a complete login.

Public clients can use code flow

A confidential web backend can authenticate to the token endpoint and retain tokens server-side. A browser then uses an application session whose security still needs attention. Public clients, including native applications and browser applications, cannot reliably keep a distributed client secret confidential. They can nevertheless use authorization-code flow with PKCE; it is not reserved for backends.

For a browser application performing its own exchange, JavaScript receives the token response. Code flow avoids access-token delivery in the authorization URL, but does not remove script compromise or token-storage risks. A backend-based design changes those boundaries rather than eliminating all browser-side risk. OAuth delegates access; an application needing user authentication should use an appropriate identity protocol such as OpenID Connect and validate its separate artifacts.

Bind the exchange to the original request

PKCE begins with a high-entropy verifier generated for the transaction. The authorization request carries its S256 challenge; the token request supplies the verifier. The authorization server checks that they correspond. The verifier is not a static client password and must remain associated with the initiating transaction. An intercepted code alone should not be sufficient to redeem it.

RFC 9700 requires PKCE for public clients and recommends it for confidential clients. Use S256 rather than a downgrade to plain. Confidential clients still perform their configured client authentication; PKCE does not replace that responsibility. Client authentication also need not mean transmitting a shared client secret: deployments can use asymmetric mechanisms.

Bind each code to its client and redirect URI, keep its lifetime short, and enforce single use. Compare registered redirect URIs exactly, with the standardized native-loopback port exception where applicable. When redirect_uri appeared in the authorization request, its token-request value must match. Reject unknown callbacks at authorization time rather than waiting until a stolen code reaches the token endpoint.

When it breaks

Single use does not prevent an attacker from redeeming an unprotected code first. Expiry bounds the opportunity without making interception harmless. Logs, browser history, referrers, and crash reports can still expose callback codes. Avoid recording them, use appropriate HTTPS redirect endpoints, and apply the specific native-loopback rules when building native clients.

Clients must prevent callback CSRF and code injection. Securely bind transaction state to the initiating browser session, and use the relevant state, PKCE, or OpenID Connect protections according to the protocol and provider guarantees. A random state value checked without session binding is not enough. Multiple authorization servers also require protection against issuer mix-up; a successful exchange alone does not establish the expected identity provider.

Refresh-token protection is a separate lifecycle. For public clients, the security best practice requires sender-constrained refresh tokens or rotation. Rotation with reuse detection is one solution, not a universal claim that every unchanged refresh token is infinitely valid. Expiry, revocation, inactivity limits, and client binding determine the actual exposure window.

Check it quickly

Save as oauth_code_demo.py and run python3 oauth_code_demo.py. This offline, single-threaded model uses synthetic client identifiers, an explicitly registered callback, fresh random codes, and an injected clock. The helper receives the verifier to compute the challenge locally; a real authorization request sends only the challenge, never the verifier.

import base64
import hashlib
import hmac
import re
import secrets

REGISTERED = {'demo-client': 'https://app.example.test/callback'}
CODES = {}
NOW = 1_800_000_000


def challenge(verifier):
    if not re.fullmatch(r'[A-Za-z0-9._~-]{43,128}', verifier):
        raise ValueError('invalid verifier syntax')
    return base64.urlsafe_b64encode(hashlib.sha256(verifier.encode('ascii')).digest()).rstrip(b'=').decode()


def authorize(client, redirect, verifier, now):
    if REGISTERED.get(client) != redirect:
        raise ValueError('unregistered redirect')
    code = secrets.token_urlsafe(32)
    CODES[code] = {'client': client, 'redirect': redirect, 'challenge': challenge(verifier),
                   'expires': now + 60, 'used': False}
    return code


def exchange(code, client, redirect, verifier, now):
    entry = CODES.get(code)
    if entry is None:
        raise ValueError('unknown code')
    if entry['used']:
        raise ValueError('code already used')
    if now >= entry['expires']:
        raise ValueError('code expired')
    if client != entry['client'] or redirect != entry['redirect']:
        raise ValueError('client or redirect mismatch')
    if not hmac.compare_digest(challenge(verifier), entry['challenge']):
        raise ValueError('PKCE verifier mismatch')
    # Atomic only because this demonstration runs sequentially in one thread.
    entry['used'] = True
    return 'DEMO-' + secrets.token_urlsafe(32)


def rejected(label, operation):
    try:
        operation()
    except ValueError:
        print('rejected:', label)
        return
    raise RuntimeError('unexpected acceptance: ' + label)


client = 'demo-client'
redirect = REGISTERED[client]
verifier = secrets.token_urlsafe(32)
code = authorize(client, redirect, verifier, NOW)
rejected('wrong client', lambda: exchange(code, 'other-client', redirect, verifier, NOW))
rejected('wrong redirect', lambda: exchange(code, client, redirect + '/extra', verifier, NOW))
rejected('wrong verifier', lambda: exchange(code, client, redirect, 'x' * 43, NOW))
if not exchange(code, client, redirect, verifier, NOW).startswith('DEMO-'):
    raise RuntimeError('valid exchange failed')
rejected('replay', lambda: exchange(code, client, redirect, verifier, NOW))
stale = authorize(client, redirect, verifier, NOW)
rejected('exact expiry', lambda: exchange(stale, client, redirect, verifier, NOW + 60))
rejected('unregistered callback', lambda: authorize(client, 'https://other.example.test/', verifier, NOW))
print('PASS: bound code accepted once; six negative cases rejected')

Expect six rejection lines and PASS. A correct exchange succeeds once. Wrong client, altered redirect, and incorrect verifier fail before consuming that code; replay fails afterward. A second code fails exactly at its expiry, and an unregistered callback is rejected before issuance. No code or token is printed or transmitted, and no real identity provider is involved.

The model is deliberately narrower than a deployed authorization server. Its sequential check-and-mark operation does not test concurrency: production storage must consume codes atomically. It omits HTTP processing, consent, client authentication, browser-session binding, issuer validation, and refresh-token handling. Use these cases as requirements for integration tests against an isolated provider configuration; passing the in-memory exercise does not demonstrate the full login journey.

Further reading

Sources