The Problem with Public Clients

Server-side apps can keep a secret. They store a client_secret, send it with every token request, and the auth server knows the request is legitimate.

Mobile apps and SPAs can’t. Any secret you ship in a JavaScript bundle or a compiled app can be extracted. There’s no such thing as a confidential public client.

So what stops an attacker who intercepts your authorization code from exchanging it for tokens themselves? Without any additional protection, nothing.

That’s the problem PKCE solves.

PKCE: Proof Key for Code Exchange

PKCE (RFC 7636) extends the OAuth Authorization Code flow so that only the client that started the flow can finish it — without needing a static secret.

The idea is a one-time cryptographic proof generated fresh for each login attempt.

How it works

Client                          Authorization Server
  |                                      |
  |-- 1. Generate code_verifier          |
  |       (random string, 43-128 chars)  |
  |                                      |
  |-- 2. Derive code_challenge           |
  |       SHA256(code_verifier) → Base64URL
  |                                      |
  |-- 3. Auth Request + code_challenge ->|
  |       (server stores the challenge)  |
  |                                      |
  |<-- 4. Authorization Code ------------|
  |                                      |
  |-- 5. Token Request + code_verifier ->|
  |       server runs:                   |
  |       SHA256(verifier) == challenge? |
  |                                      |
  |<-- 6. Access Token -----------------|

Step 1 — Generate the verifier

import secrets
code_verifier = secrets.token_urlsafe(64)  # cryptographically random

Step 2 — Derive the challenge

import hashlib, base64
digest = hashlib.sha256(code_verifier.encode()).digest()
code_challenge = base64.urlsafe_b64encode(digest).rstrip(b'=').decode()

Step 3 — Authorization request

GET /authorize?
  response_type=code
  &client_id=...
  &redirect_uri=...
  &code_challenge=<code_challenge>
  &code_challenge_method=S256
  &state=...

Step 4 — Token exchange

POST /token
  grant_type=authorization_code
  &code=<auth_code>
  &redirect_uri=...
  &code_verifier=<code_verifier>
  &client_id=...

The server re-hashes the verifier and checks it against what it stored. Match → tokens issued. No match → rejected.

Who holds what

  • Client keeps the code_verifier — the raw secret, never sent until the final step
  • Server keeps the code_challenge — the hash, tied to the authorization code it issued

An attacker who intercepts the authorization request gets the code_challenge. An attacker who intercepts the redirect gets the code. Neither is enough. They’d need the code_verifier too, which never leaves the originating client.

Always use S256, never plain

code_challenge_method=plain means code_challenge == code_verifier. Anyone who sees the initial auth request has everything they need. S256 means you’d need to reverse SHA-256 to get from the challenge back to the verifier. That’s not happening.

Wallet Auth: The Same Idea, Different Cryptography

There’s a pattern used in Web3 authentication — Sign-In with Ethereum (SIWE) and similar flows — that follows the same challenge-response shape, but uses asymmetric cryptography instead of hashing.

The server issues a challenge. The client proves they control a private key by signing it. The server verifies the signature against the public wallet address.

Client                          Server
  |                                |
  |-- 1. Request challenge ------->|
  |       + wallet address         |
  |                                |
  |<-- 2. Random nonce ------------|
  |       (tied to that address)   |
  |                                |
  |-- 3. Sign nonce with wallet    |
  |                                |
  |-- 4. Send signature ---------->|
  |                                |
  |   5. Server recovers address   |
  |      from signature, checks    |
  |      it matches stored address |
  |<-- 6. Authenticated -----------|

The server never sees the private key. It just uses the public wallet address to verify the signature — asymmetric crypto means only the holder of the private key could have produced that exact signature for that exact nonce.

Send the address upfront

Some implementations send the wallet address in step 4 alongside the signature. But it’s cleaner to send it in step 1 when requesting the challenge:

  • The server ties the nonce to that address from the start
  • Step 4 is just the signature — less surface area for mistakes
  • No ambiguity about which address is being claimed at verification time

Storing challenges in MongoDB

The server needs to temporarily store the mapping between wallet address and nonce until the signature comes back. A simple document:

{
  "wallet": "0x1234...abcd",
  "nonce": "f3a9c2...",
  "createdAt": "2026-02-27T10:00:00Z"
}

Once verified, discard it immediately — a nonce that can be reused is a replay attack waiting to happen.

For automatic cleanup, put a TTL index on createdAt:

db.challenges.createIndex({ createdAt: 1 }, { expireAfterSeconds: 300 })

MongoDB deletes stale challenges after 5 minutes, no cron job needed.

The Pattern Underneath Both

PKCE and wallet auth look different on the surface but share the same structure:

PKCEWallet Auth
Client generatescode_verifier (random string)private key signature
Client sends upfrontcode_challenge (hash of verifier)wallet address
Server storeschallenge tied to auth codenonce tied to wallet address
Client proves latersends raw code_verifiersends signature
Server verifies byhashing verifier, comparingrecovering address from signature
Crypto typeSymmetric (SHA-256)Asymmetric (ECDSA)

Both are challenge-response protocols. The client commits to something upfront, and later proves they were the one who made that commitment — without ever sending the actual secret directly.

The nonce / verifier is what makes both flows replay-proof. Capture the challenge, capture the code, capture the signature — none of it is reusable without the original secret that produced it.