DeepConcepts

Security / security / authorization / oauth

The Client Secret Is Not What Makes the Code Flow Safe

The misconception

That the client secret is what makes the authorization code flow secure, so a flow with a correctly authenticated client is safe. The secret proves which application is calling the token endpoint. It proves nothing about where the code came from, which browser session it belongs to, or whether it has already been redeemed. The checks that carry that weight are the exact-match redirect_uri comparison, single-use code semantics, and the PKCE binding — and a public client such as a browser app or a mobile app has no secret at all yet is not thereby insecure.

14 min

The authorization code grant is two conversations, not one. A browser redirect carries a short-lived one-time code past the user, their address bar, their history and their extensions. A direct call from your server to the identity provider trades that code for tokens. The client secret belongs to the second conversation only, and it answers one question: which application is calling. It answers nothing about where the code came from.

Three terms first, because the rest of the lesson leans on them. The authorization server is the identity provider that logs the user in and mints tokens. The client is your application, which wants a token. A public client is one whose code runs where the user can read it — a single-page app in a browser, a mobile app on a phone — so it has no secret at all; a confidential client is server-side code that can keep one.

The panel below is the authorization server's token endpoint: the URL the client calls to trade a code for tokens. On the left you set what the incoming request looks like and where its code came from. On the right you choose which checks this endpoint performs. The log prints each check, its verdict, and its reason.

Start with follow the diagram — client authentication on, everything else off — and leave the situation legitimate. It works. Now change the code's provenance without touching the client's credentials, and watch the endpoint keep saying yes.

The token request as it arrives
What this token endpoint checks

Finding the code and checking it has not expired always runs. A code the server cannot find is not a code.

this endpoint says
a fully compliant endpoint says
stopped at
handed over
The four messages — all identifiers illustrative

      

The code crosses the front channel, where the browser, its history, its extensions and any Referer header can see it. The tokens never do. That asymmetry is the entire reason the grant has two steps.

Token endpoint decision

check ran · nothing to check · the gap this request walked through

The combination worth sitting with is a different authorization request plus correct client secret plus no PKCE. Client authentication passes, because the real client really is calling. The code is unexpired, unused, and was issued by this very server. Nothing is forged. Every check on the right can be switched on and the endpoint still says yes, because none of them asks the only question that matters: is this the code that this browser session produced. That question has exactly one server-side answer, and it is PKCE.

What the two channels are actually for

The grant exists because of one asymmetry: the browser is a courier you do not control, and your server-to-server connection is one you do.

Everything on the front channel travels as a URL. RFC 6749 §4.1.1 and §4.1.2 define both hops as query parameters on redirects, which means the authorization request and the code land in the address bar, in browser history, in the client's web server access log, and potentially in a Referer header sent to a third party. RFC 9700 §4.2 and §4.3 catalogue exactly these leaks: the code reaching an advertiser's iframe because the callback page rendered third-party content, or sitting in history on a shared machine.

So the front channel is given something that is nearly worthless on its own. RFC 6749 §10.5 requires the authorization code to be short lived and single-use, and it is redeemable only by a party that can also satisfy the token endpoint. The valuable artefacts — the access token and the refresh token — are handed over on the back channel in message 4 of the panel, and never touch the browser at all. That is the whole design. The older implicit grant put the access token in the front channel instead, which is why RFC 9700 §2.1.2 tells clients not to use it.

This is also the answer to a question that comes up in every migration: why the token request has to repeat redirect_uri when the server already knows it. It is not routing information at that point — the token response goes back over the same connection. It is an assertion by the client about where it believes the code was delivered, and the server compares that assertion with what it actually did. RFC 6749 §10.6 spells out the manipulation this prevents.

What the client secret proves

The client secret authenticates the application to the token endpoint. That is its whole job, and it is a real job: RFC 6749 §4.1.3 requires the server to authenticate confidential clients and to ensure the code was issued to the authenticated client. Without it, anything that obtained a code could walk up to the token endpoint and redeem it.

Now the three things it does not do, each of which someone ships every week.

  • It says nothing about the user. The secret is a property of your deployment, identical for every request your service makes, for every user. It is not evidence that a particular person logged in.
  • It says nothing about the code. Set the panel to a different authorization request with a correct secret. Client authentication passes and the wrong code is redeemed. The secret proves who is asking; the code's provenance is a separate fact requiring a separate check.
  • It cannot exist in a browser or a mobile app. Anything shipped to a user's device is readable by that user. A secret in a single-page app's JavaScript bundle, or in a mobile binary, is a published string. This is why RFC 6749 §2.1 defines the public client type at all.

The last point is where the misconception does real damage, because it pushes teams towards two bad conclusions. One is to embed a secret in a public client anyway and feel protected. The other is to conclude that public clients cannot use the code flow and to reach for something weaker. Neither follows. RFC 9700 §2.1.1 states the actual rule: public clients MUST use PKCE, confidential clients SHOULD, and authorization servers MUST support it. PKCE replaces the secret's role in this specific check — proving the redeemer is the requester — using a value generated per request rather than a value baked into a build. PKCE and state cover different threats, and that distinction is worth its own hour.

RFC 9700 §2.5 adds the direction of travel for clients that do have secrets: it recommends asymmetric client authentication — mutual TLS or a signed assertion — over a shared string, so the authorization server never stores anything that would let it impersonate its own clients.

Where implementations go wrong

Four failures, in roughly the order they show up in production.

Redirect URI matching by pattern. A registered value of https://app.example.test/* looks like a convenience for handling /callback and /callback/return. It also accepts every other path on that host, including any page that reflects a parameter or performs a redirect of its own. RFC 9700 §4.1.3 is explicit: use exact matching, defined as simple string comparison from RFC 3986 §6.2.1, with one exception for native apps on localhost, which may vary the port number. If your provider's console offers wildcards, the wildcard is the finding.

Single use enforced as "mark it used" rather than "and revoke". Turning off single use in the panel mints two grants from one authorization. Enforcing it correctly is more than refusing the second call: RFC 6749 §4.1.2 and §10.5 both say the server SHOULD revoke the tokens already granted from that code, because a second redemption is evidence that two parties held the code. Refusing the second call and keeping the first set of tokens live preserves the outcome you were trying to prevent.

Treating the recommended maximum as the target. RFC 6749 §4.1.2 says the code MUST expire shortly after issue and RECOMMENDS a maximum lifetime of ten minutes. That is a ceiling, not a setting to aim at, and the difference matters because the code spends its life somewhere you do not control: an address bar, a history file, a reverse proxy access log. The window you choose is the window in which a copy of that code is still worth something. The redemption itself is one server-to-server call, so the honest question is not how long a code may live but how long yours needs to — and the answer for a healthy client is seconds. Check what your provider actually uses; it is a documented, and sometimes configurable, number.

Accepting a code_verifier for a code with no challenge. Select code_verifier sent, but this code carries no challenge in the panel. With PKCE enforcement on, it is rejected; with it off, the presence of the verifier looks like proof and is not. RFC 9700 §2.1.1 makes the rejection a MUST on the server, precisely because a client cannot tell from the outside whether its verifier was checked.

What the client has to do, which is not nothing

Everything above is the authorization server's side. The client has three obligations of its own, and they fail independently.

Check state before doing anything else. The callback is a cross-site GET that a browser will happily make because something told it to. RFC 6749 §10.12 requires the client to bind that request to the user agent's authenticated state, and state is the parameter for it. See why cross-site requests arrive at all.

Do not use the presence of an access token as proof of login. An access token says an authorization exists; it does not say who is in front of you. That is what OpenID Connect's ID token is for, and it comes with its own binding parameter. Then the API on the other end has to actually check it — validating a JWT is not verifying its signature.

Treat the whole flow as conditional on TLS. Every hop names a host in a URL and assumes the connection reaches that host. Certificate validation is four separate checks, and a client that skips one of them makes every guarantee above conditional on a name it never verified.

Checking your own deployment

Five things you can look at tomorrow, in order of how often they turn something up.

  • The client registration. Open the provider console and read the registered redirect URIs. Look for a *, a bare host, an http:// entry that is not localhost, and any leftover URI from a staging environment. Each one is a place a code can be delivered.
  • The discovery document. Fetch /.well-known/openid-configuration or /.well-known/oauth-authorization-server and look for code_challenge_methods_supported. RFC 9700 §2.1.1 recommends publishing it, and if S256 is not listed you cannot assume your verifier is being checked at all.
  • The authorization request your client actually builds. Not the documentation — the URL. Open developer tools, filter on the authorize endpoint, and confirm state and code_challenge_method=S256 are both present. Wrapper libraries drop these when a configuration key is misspelled, silently.
  • Code reuse, in staging. Capture one code and redeem it twice. The second call must return invalid_grant. Then check whether the tokens from the first redemption still work. If they do, the revocation half of RFC 6749 §10.5 is not implemented.
  • Your invalid_grant rate. It is one error string for expired codes, reused codes, mismatched redirect URIs and failed PKCE verification. Have your client log which of its own preconditions held when it got one — code age, whether this was a retry, which redirect URI it sent — because the server will not tell you and RFC 6749 §5.2 does not require it to.

On that last point: the sparse error is deliberate. A token endpoint that explained itself would be explaining itself to whoever asked. Your logs have to carry the context the response does not, which means logging the shape of the request and never its contents — a code or a token in an application log is a credential in an application log.

Your server-side client authenticates to the token endpoint with a correct secret. The provider enforces exact redirect URI matching, single-use codes and 60-second lifetimes. You do not use PKCE, and your client does not check state. What is still unverified?

Next, the two parameters that close that gap and are constantly confused with each other — PKCE and state — and then what the refresh token in message 4 costs you later, in why you cannot recall a token you already issued.

Why this concept is on the site

Topics are chosen from places engineers visibly get stuck, and the sources are kept with the lesson so the claim is checkable.