Evolution of AuthN/AuthZ Architecture (3): Advanced Client Authentication and Migration Strategy
This series comes in five parts.
- Part 1: From Monolith to an Authentication Platform
- Part 2: API Gateway and Microservices
- Part 3 (this article): Advanced client authentication (PKCE / DPoP) and a step-by-step migration strategy
- Part 4: The Internal Structure of an Authorization Server and Designing for Reliability
- Part 5: Data Model and Operational Design of a Home-Grown Authorization Server
Parts 1 and 2 traced the server-side architecture. Part 3 shifts the lens to the client. No matter how robust the machinery that "issues and verifies" tokens is, one problem remains: if the token is stolen, it's game over. Almost every recent specification (PKCE, DPoP, FAPI) has evolved in the direction of strengthening this "resistance to theft."
The Fundamental Weakness of Bearer Tokens
By default, an OAuth 2.0 access token is a Bearer token (RFC 6750). "Bearer" means that whoever holds it is treated as its rightful ownerâthe same model as a train ticket or a banknote. No one asks who issued it.
This simplicity fueled its adoption, but the threat model is stark: steal it and you can use it. There are mainly three routes by which tokens get stolen.
- Interception of the authorization code (front channel) â addressed by PKCE
- Token leakage via traffic, logs, or XSS (after issuance) â addressed by sender constraints (DPoP / mTLS)
- Client impersonation (at token request time) â addressed by stronger client authentication
We'll look at these three in that order.
The Public Client Problem and PKCE
Premise: Two Classes of Client
| Confidential Client | Public Client | |
|---|---|---|
| What it is | Server-side app | SPA, mobile app, CLI |
| Can it hold a secret? | Yes (kept confidential inside the server) | No (binaries get decompiled, JS can be read in full) |
| Authentication at token request | Authenticates with client_secret, etc. | No means of authentication |
A public client cannot hold a secret. This means the authorization server cannot confirm "whether the party presenting the authorization code is really that app." This is where a concrete attack becomes possible: the authorization code interception attack. A mobile OS's custom URL scheme (myapp://callback) can be registered by multiple apps under the same scheme, so a malicious app can intercept the redirect, steal the authorization code, and exchange it for a token itself.
How PKCE Works
PKCE (Proof Key for Code Exchange, RFC 7636) solves this with a one-time passphrase created on the spot.
The key point is that only the hash value (challenge) flows over the front channel, while the original (verifier) is presented for the first time over the back channel during the token exchange. An attacker who intercepts the authorization code does not know the verifier, so they cannot exchange the code for a token. In effect, it turns the redirectâa route that can be interceptedâinto a route where only meaningless data flows even if intercepted. It's a small specification, but I find it beautiful as a piece of design.
Originally an extension for mobile, it is now recommended for all clients, including confidential clients, and will be mandatory in OAuth 2.1. That's because it also works as a defense against code injection attacks (where an attacker slips their own code into a victim's session) even for clients that hold a secret. Since the cost of adoption is essentially zero (major libraries already support it), if you currently have an authorization code flow running without PKCE, that is the top-priority improvement.
The SPA Dilemma and the BFF Pattern
An SPA can handle code interception with PKCE, but a deeper problem remains. No matter where you put the acquired token in the browser, you cannot fully protect it from XSS (localStorage is out of the question, and even keeping it in memory means it can be stolen by XSS while the page is running).
The current strong answer to this is the BFF (Backend for Frontend) pattern. The token is never handed down to the browser at all; the BFF server holds it. The space between the browser and the BFF is protected by a traditional session cookie (HttpOnly + SameSite).
What's interesting is that this looks like a return to the session cookie we used in Stage 0 of Part 1. In reality it's a combination of the optimal solution for each segmentâ"cookies are safest for browserâserver, tokens are most flexible for serverâAPI"âso it's not a regression. That said, you do pay the cost of building and operating an additional server component, the BFF. I think the design debate around "SPA + authentication" ultimately comes down to a trade-off between the risk of putting tokens in the browser and the cost of operating a BFF.
Strengthening Client Authentication
There is also a ladder of strength in how a confidential client proves itself at the token endpoint.
| Method | How it works | Weakness / characteristics |
|---|---|---|
| client_secret_basic / post | Sends a shared secret every time | The secret exists on the wire, in logs, and in the AS-side DB. A wide leakage surface |
| private_key_jwt | Presents a JWT signed with a private key (the public key is registered with the AS) | The secret never travels the network. Since the AS holds only the public key, impersonation is impossible even if the AS side leaks |
| tls_client_auth (mTLS, RFC 8705) | Authenticates with a client certificate | Top-tier strength. But certificate lifecycle management is heavy |
The pattern is "from shared secret to public-key cryptography," analogous to the evolution from passwords to WebAuthn. The underlying principle is that the moment you share a secret, the leakage surface doubles. This is why FAPI, the financial-grade profile, requires private_key_jwt or mTLS. If you're in the position of providing a B2B API, it's worth making private_key_jwtârather than client_secretâthe standard for new clients.
Sender-Constrained Tokens: DPoP
The last remaining threat is "token theft after issuance." As long as it's a Bearer token, anyone can use a token leaked via log exposure, a man-in-the-middle, or XSS. What blocks this is sender-constrained tokensâa mechanism that binds a token so only the holder of a specific key can use itâand there are two practical options.
- mTLS certificate binding (RFC 8705): Embeds a hash of the client certificate in the token, and the RS cross-checks it against the certificate at the TLS layer. Powerful, but operationally hard to route mTLS through configurations that go via a browser or a Gateway/CDN.
- DPoP (Demonstrating Proof of Possession, RFC 9449): Performs proof of possession at the application layer. A solution for environments where mTLS is unavailable (SPA, mobile).
How DPoP Works
The client generates a key pair and, for each request, attaches to the header a one-time JWT (a DPoP Proof) signed with that private key.
POST /resource HTTP/1.1
Authorization: DPoP <access_token>
DPoP: <DPoP Proof JWT>
A DPoP Proof contains the following:
| Claim | Contents | What it prevents |
|---|---|---|
jwk (header) | The client's public key | â (the anchor for verification) |
htm / htu | HTTP method and URI | Reusing the Proof against a different endpoint |
iat + jti | Issuance time and a unique ID | Replay of the Proof itself |
ath | Hash of the access token | Combining it with a different token |
On the access-token side, meanwhile, a hash of the public key (cnf.jkt) is burned in at issuance. The RS verifies that the "key hash inside the token" matches the "signing key of the Proof." As a result, even if you steal only the token, you can't use it without the corresponding private key. You could say the token has gone from a ticket to a "commuter pass with a photo on it."
The Limits of DPoP and Where It Fits
To be honest, DPoP is not a silver bullet.
- Powerless if the key is stolen too. In an SPA you can protect the key with WebCrypto's
extractable: false, so you can prevent the key from being exfiltrated by XSSâbut you can't prevent an attack where XSS has a Proof created on the spot while it's running. What DPoP shrinks is the attack surface of "using a stolen token later, or somewhere else." - Server-side implementation burden: Replay checking of
jtirequires state management, and handling clock skew also requires a nonce mechanism (the AS hands out aDPoP-Nonce). Some of the simplicity of "stateless JWT verification" is lost. - Support is still maturing across ASs, RSs, and libraries alike, so adoption should proceed while watching the ecosystem mature.
The decision of whether to apply it therefore depends on "the value of the asset you're protecting." For financial APIs and public clients holding long-lived refresh tokens (mobile apps), the return on investment is high. Applying it uniformly to an ordinary internal CRUD API is, at this point, overkill in my view.
Overall Summary: Architecture Pattern Ă Problem Ă Difficulty
Let me compress the whole series onto a single sheet.
| Stage | Pattern | Problem it solves | Technical difficulty | Organizational difficulty |
|---|---|---|---|---|
| 0 | Monolith + session | (Sufficient for a single app) | Low | Low |
| 1 | Introduce IdP + OIDC | SSO, centralized password management, unified MFA, auditing | Medium | MediumâHigh |
| 1.5 | Make PKCE mandatory | Authorization code interception, code injection | Low | Low |
| 2 | Consolidate on an API Gateway | Inconsistent verification implementations, cross-cutting policy, auditing | Medium | Medium |
| 2.5 | Phantom Token | Balancing prevention of external leakage, immediate revocation, and JWT performance | Medium | Low |
| 3 | Token Exchange / service-to-service auth | Confused deputy, least privilege internally | MediumâHigh | Medium |
| 3.5 | Mesh + PDP/PEP | Workload identity, centralized policy management | High | High |
| 4 | private_key_jwt / mTLS | Client impersonation, the leakage surface of shared secrets | Medium | Medium |
| 4.5 | DPoP / certificate binding | Reuse of stolen tokens | High | Medium |
| Suppl. | BFF | The impossibility of protecting tokens in the browser | Medium | Low |
Looking at it, what you notice is that cost-effectiveness is not ordered by Stage number. PKCE (1.5) is almost free and highly effective, while DPoP (4.5) is expensive and its effect is narrow. The numbers are the order of historical evolution, not the order of investment priority.
How Should a Step-by-Step Migration Proceed?
Finally, here is a realistic migration order starting from a legacy state. The principle is the strangler patternâavoid a big-bang migration and replace the old system incrementally while strangling it.
Recommended Order
- Stand up an IdP and connect new apps to it first (don't touch existing ones). Stabilizing IdP operations is the top priority. Make PKCE standard at this point.
- Turn the existing monolith into an RP. Swap the login screen for an IdP redirect and migrate the user DB. This is the grubbiest part of the whole process and the place where you should budget the most time. For legacy apps that can't be modified, wrap them with an authentication proxy.
- Introduce the API Gateway as a thin layer doing JWT verification only. Don't make it multifunctional from the start. Build an operational track record on three things: routing + authentication + logging.
- Do monolith decomposition and service-to-service auth in parallel. From the new services carved out by decomposition, make Client Credentials / audience (
aud) verification a convention. Don't force yourself to touch internal calls within the old monolith. - Add advanced measures only where the risk is high. Phantom Token for externally exposed APIs, private_key_jwt for partner integrations, DPoP for financial operationsâbased on asset value, not blanket application.
Common Failures in Migration
- Aiming for a big-bang switchover: Since authentication is the prerequisite for every feature, the blast radius of a failure reaches everywhere. Always set up a period where old and new run in parallel, and switch over app by app.
- Deferring the lifetime design of sessions and tokens: Unless you design up front the consistency of the four lifetimesâ"IdP session," "app session," "access token," and "refresh token"âyou'll discover "getting logged out on its own" or "still getting in after logging out" from the user's side.
- Trying to perfect governance first: As stated in Part 2, controls where the cost of compliance > the cost of circumvention get circumvented. Ship guardrails and self-service together with the controls.
- Starting from the latest specs: DPoP and mesh only start to matter once the Stage 1â2 foundation is in place. You can't put a roof where there's no groundwork.
Questions for Making the Call
When drawing up a migration plan, here's how I would ask myself.
- Which problem in the table above is the incident that is actually happening (or could happen) right now?
- What is the smallest Stage that solves that problem?
- Are the organizational prerequisites of that Stage (a dedicated team, an operational structure) met?
Racing the architecture ahead without being able to answer these three is, I think, the single most common failure pattern. Authentication and authorization architecture is the work of deciding not "how far can we go" but "where is it right for us to stop." The value of knowing the historical evolution lies not in keeping up with the cutting edge, but in situating our own current position and problems within that history, and choosing correctly just the next single move.
In Part 4 (The Internal Structure of an Authorization Server and Designing for Reliability), I move the lens inside the authorization server, covering patterns for separating authentication from token issuance, the design of availability and key management, and yardsticks of quality such as FAPI and the Conformance Suite. It is an appendix for those in the position of selecting, building, and operating an AS.
