When to use PKCE

To those of us newer to Identity security, there’s a rarely-covered topic that I want to make sure I touch on. OAuth and PKCE..

We need to talk about PKCE, why client secrets are not the hammer for every nail, and (more importantly in my opinion) when you should actually be using one and not the other.


First: What Problem Are We Solving?

I’m going to assume you already know about OAuth 2.0’s Authorization Code flow. There are a few different OAuth flows, but authorization code is most common for public-facing applicaitons. The issue is that this flow has a specific problem…

The authorization server takes the request and redirects the user back to your app with a code. Your app then exchanges that code for tokens. Fine. Cool. Easy enough.. But what’s stopping someone from intercepting that plain text code in transit and doing the exchange themselves before your app does?

The original answer to this connumndrum was a client_secret. Your app takes this secret and proves it’s the legitimate recipient by sending it to the authorization server. The authorization server essentially used the client ID as the application’s username, and the client secret as the applications password. No secret means no tokens.

That worked well 20 years ago when apps were almost solely server-side and secrets could actually be kept secret. Then mobile apps happenedm then SPAs (Single Page Applications), then CLIs… And suddenly you had developers who are not security or identity conscious trying to, or worse, successfully embedding a client secret into JavaScript that ships to the browser, or into an APK that anyone can reverse engineer by inspecting a page.

Client secrets in public clients aren’t secrets, they’re there for everyone to find.


How PKCE Actually Works

Then came PKCE. PKCE (Proof Key for Code Exchange), pronounced “pixy” if you want to sound like you’ve done this before, is the fix. It was introduced in RFC 7636 specifically for mobile and public clients. As of OAuth 2.1, it’s recommended for ALL clients, including confidential ones.

Here’s what the flow actually looks like:

Step 1: Your app generates a random string. This is the code_verifier. It’s generated fresh per authorization request and is typically 43 to 128 characters of URL-safe, random, ephemeral (one time use) data.

Step 2: Your app hashes it. You then hash the code_verifier with SHA-256 to produce the code_challenge. This is what gets sent to the authorization server at the start of the flow.

code_verifier  = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
code_challenge = BASE64URL(SHA256(code_verifier))
               = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"

Step 3: Authorization request includes the challenge.

GET /authorize?
  response_type=code
  &client_id=my-app
  &redirect_uri=https://myapp.example.com/callback
  &code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
  &code_challenge_method=S256
  &scope=openid profile

Note - Up to this step is usually handled by your authentication server’s SDK (SDK dependent of course)

Step 4: User authenticates. Authorization server hands back the code to the client and the server stores the code_challenge tied to that code.

Step 5: Token exchange includes the original verifier.

POST /token
  grant_type=authorization_code
  &code=SplxlOBeZQQYbYS6WxSbIA
  &redirect_uri=https://myapp.example.com/callback
  &client_id=my-app
  &code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk

Step 6: Server hashes the verifier and checks it against the stored challenge. If they match, you get tokens. If they don’t, or if someone intercepted the code and is trying to use it without the correct verifier, the exchange fails.

The elegance here: even if someone grabs the code out of a redirect or sniffing attack, they don’t have the code_verifier and can’t find out the real one so the only entity that can complete the flow is the one that started it.


Client ID and Secret: When It Still Makes Sense

Saying this, I want to double down that client secrets aren’t dead. They can and should still be used, just under the right circumstances.

A client secret is appropriate when your app is a confidential client, meaning it runs in an environment where the secret can actually be kept secret. Server-side web apps, backend services, machine-to-machine (M2M) flows using the Client Credentials grant.. These are all still legitimate use cases where PKCE is not mandatory.

In a Client Credentials flow, no user is involved. (think service-to-service auth). That would look something like the traditional client/secret auth. The only difference is that the grant_type is client_credentails, not authorization_code:

POST /token
  grant_type=client_credentials
  &client_id=service-a
  &client_secret=s3cr3t
  &scope=api.read

In this example, the server verifies the client_id and client_secret, issues an access token, and bam. You’re done. There’s no user to redirect or authorization code to intercept. The secret travels over TLS between two servers in a controlled environment.

And that’s fine. That’s what it’s designed for.

Where it breaks down is when you take this mental model and try to apply it to a React app, mobile app, or Electron desktop client. The second that secret exists anywhere someone can inspect it, you’ve got no security.


The Part People Get Wrong

Ok. Soap box time.

I have seen a few half-cooked implementations (including in production) where teams use PKCE with code_challenge_method=plain.

Don’t ever do this..

plain means you send the code_verifier as the code_challenge without hashing it. So if an attacker intercepts the authorization request (which is plain text and in the URL by the by), they have the verifier. The whole point of PKCE is that the challenge is a hash and you can share a hash without revealing what generated it. This is also the key concept in how we secure user passwords. This way, even if the password gets compromised, you can’t tell what the plain text is. Hashes are only one-way.


TL;DR

  • PKCE solves code interception attacks by tying the authorization request to the token exchange cryptographically.
  • Client secrets are for confidential clients ONLY (server-side, M2M) where the secret can actually be kept secret. Not for SPAs, mobile, or desktop apps.
  • Always use code_challenge_method=[hashing algorythm]. Never plain.
  • OAuth 2.1 recommends PKCE for all clients, including confidential ones. If you’re starting a new implementation, just use it everywhere.