Teams with a server-side OAuth client often assume a client secret makes PKCE unnecessary, while teams moving to PKCE sometimes assume it lets them drop client authentication, and both assumptions leave a gap. A client secret, or better an asymmetric credential, proves which application is calling the token endpoint. PKCE proves that the party redeeming an authorisation code is the same client instance, in the same transaction, that asked for it. They answer different questions, and RFC 9700 shows an attack, authorisation code injection, that client authentication alone does not stop. This guide is about confidential clients: what each control protects, why the OAuth Security Best Current Practice and the OAuth 2.1 draft expect PKCE on top of client authentication, where private_key_jwt fits, and how to roll both out. It is for identity engineers, application architects and assessors reviewing web applications, backends for frontends and agent backends. Public client PKCE mechanics are in the PKCE Implementation guide.
Key takeaways
- Client authentication and PKCE protect different things. Client authentication says "this is application X". PKCE says "this code belongs to this transaction". Neither substitutes for the other.
- Client authentication does not stop code injection. RFC 9700 section 4.5 describes an attacker injecting a stolen code into their own session with the legitimate client, which then authenticates normally; it says asymmetric client authentication does not stop this, while PKCE does.
- PKCE is recommended for confidential clients now and required by default in OAuth 2.1. RFC 9700 says confidential clients should use PKCE. The OAuth 2.1 draft (draft-ietf-oauth-v2-1-17) requires it unless the client is confidential and the server has reasonable assurance it validates the OpenID Connect
nonce, and still recommends PKCE then. - PKCE never makes a public client confidential. A secret shipped in a mobile, desktop or browser app can be extracted; OAuth 2.1 tells servers not to accept such a secret as proof of identity.
- The strongest pattern is PKCE plus asymmetric client authentication. RFC 9700 and OAuth 2.1 recommend mTLS or
private_key_jwt, and FAPI 2.0 requires one of them together with PKCE S256, which also removes the shared secret that sprawls into config files.
The short answer
No, PKCE does not replace client authentication, and client authentication does not replace PKCE. For a confidential client using the authorisation code grant, do both: send a fresh S256 code challenge on every authorisation request, and authenticate at the token endpoint, preferably with private_key_jwt or mTLS rather than a shared secret. For a public client, PKCE is mandatory and is the only binding you have; do not pretend an embedded secret adds anything. For machine-to-machine calls with no user and no authorisation code (the client credentials grant), PKCE does not apply at all, and client authentication is the whole story.
What each control proves
| Question | Client authentication (secret, private_key_jwt, mTLS) | PKCE (RFC 7636, S256) |
|---|---|---|
| What it proves | The caller at the token endpoint holds the registered application's credential | The caller redeeming the code created the matching code challenge for this transaction |
| Scope of the proof | Every request the application makes, for every user | One authorisation code, one transaction, one client instance |
| Lifetime of the credential | Months or years for secrets; minutes for a signed assertion, but the key persists | Single use; a new verifier for every flow |
| Who can use it | Only confidential clients that can keep a credential | Every client, public or confidential |
| Grants it applies to | Token endpoint calls for all grants, plus introspection and revocation | Authorisation code grant only |
| Stops a stolen code being redeemed by a different client | Yes, for confidential clients: the attacker lacks the credential | Yes: the attacker lacks the verifier |
| Stops a stolen code being injected into the legitimate client | No (RFC 9700 section 4.5) | Yes (RFC 9700 section 4.5.3.1) |
| Provides CSRF protection on the redirect | No | Yes, once the client has confirmed the server supports PKCE (RFC 9700 section 4.7) |
| Stops client impersonation for app-only tokens | Yes, while the credential stays secret | No, not involved |
| Leaves a long-lived secret to sprawl | Yes for shared secrets; no for asymmetric keys held in a KMS or HSM | No |
Threat model: client secret only
Architecture in words
A server-side web application redirects the browser to the authorisation endpoint with its client_id, redirect_uri, scope and state. The user signs in, the authorisation server sends a code to the redirect URI, and the application's backend exchanges the code at the token endpoint, authenticating with client_secret_basic or client_secret_post. The OAuth 2.1 draft says servers must support credentials in the request body and may support HTTP Basic.
What goes wrong
- Code injection: an attacker obtains a victim's code (from a leaked redirect, a referrer header, browser history or a log) and starts their own login with the legitimate application, swapping in the stolen code at the redirect step. The application's backend redeems it with its own valid secret and logs the attacker into the victim's account. The secret worked exactly as designed, which is the point: it authenticated the right application, not the right transaction.
- Login CSRF: without
statecorrectly bound to the session, an attacker can push their own code into the victim's browser. PKCE ornoncegives the same protection asstate, and RFC 9700 notes PKCE is robust even against an attacker who can read the authorisation response. - Secret theft: a shared secret sits in environment variables, CI variables, Helm values and support tickets. Whoever copies it can call the token endpoint as the application (MITRE ATT&CK T1552.001 Credentials In Files), including for app-only tokens with no user involved. The OAuth 2.1 draft notes that a confidential client whose credentials are stolen can be impersonated.
- Credential addition: attackers with directory rights add their own secret to an existing application (T1098.001 Additional Cloud Credentials), which is invisible if nobody reviews application credentials.
Threat model: PKCE only
Architecture in words
The client generates a random code_verifier (43 to 128 characters, 256 bits of entropy recommended by RFC 7636), sends its S256 hash as code_challenge on the authorisation request, and sends the verifier with the code at the token endpoint. The server checks the hash. No client credential is presented.
What goes wrong
- Any party can be the client: PKCE binds a code to whoever made the challenge, and an attacker can make their own challenge. For a public client, an attacker's script using the same
client_idruns a complete, valid flow. That is why the OAuth 2.1 draft says redirect URI checks and similar measures do not authenticate a native client, and why consent should not be silently remembered for unauthenticated clients. - Phishing with a real client ID: the authorisation server cannot tell the real app from an impostor, so consent phishing and device or redirect lures work as well against PKCE-only clients as against any public client. See the Consent Phishing and Illicit OAuth Grants Guide.
- Token theft after issuance: PKCE protects the code, not the tokens. Stolen access or refresh tokens need sender constraint (DPoP or mTLS) and refresh token rotation. See the DPoP Guide.
- Downgrade and plain: a server that accepts a verifier when no challenge was sent, or accepts the
plainmethod, can be downgraded. RFC 9700 section 4.8.2 requires rejecting the first; the OAuth 2.1 draft forbidsplain.
For a confidential client, dropping client authentication because PKCE is present would turn it, in effect, into a public client: anyone with the client ID could run flows in its name and, if the client also has the client credentials grant, nothing would protect it at all.
What the standards require
| Source and status | PKCE for confidential clients | Client authentication |
|---|---|---|
| RFC 7636 (Standards Track, September 2015) | Written for public clients; S256 mandatory to implement on the server; plain should not be used in new implementations | Notes that secrets in client binaries cannot be considered confidential |
| RFC 9700, OAuth 2.0 Security BCP (BCP 240, January 2025) | Section 2.1.1: confidential clients should use PKCE; OpenID Connect confidential clients may use nonce instead with the section 4.5.3.2 precautions; servers must support PKCE | Section 2.5: asymmetric methods (mTLS, private_key_jwt) recommended |
| OAuth 2.1, draft-ietf-oauth-v2-1-17 (Internet-Draft, 31 October 2026) | Clients must use PKCE and servers must enforce it, except a confidential client where the server has reasonable assurance it implements nonce; PKCE still recommended then; plain forbidden | Server must require client authentication for confidential clients; asymmetric methods recommended; no shared secrets required of native apps |
| FAPI 2.0 Security Profile (Final, 22 February 2025) | PKCE with S256 required, plus pushed authorisation requests | Confidential clients only; mTLS or private_key_jwt only |
| OWASP ASVS 5.0 | V10.4.6: server requires a valid code challenge, refuses plain and validates the verifier | V10.4.16: confidential client with strong, replay-resistant public-key client authentication |
OAuth 2.1 is still a working group draft (IESG state "I-D Exists" when checked on 14 November 2026), so treat its exact wording as subject to change. Its direction matches RFC 9700, which is already published. The draft also explains why PKCE is preferable to nonce: the server can refuse an injected code before issuing any token, whereas the nonce check happens in the client after tokens exist. See the OAuth 2.0 Security Best Current Practice (RFC 9700) Guide and the OAuth 2.1, GNAP and FAPI Guide.
Where private_key_jwt and mTLS fit
The question in the title is usually really two questions: should a confidential client add PKCE (yes), and should it keep a shared secret (preferably not). Replacing the secret with an asymmetric method answers the second without weakening the first.
- private_key_jwt (RFC 7523): the client sends
client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearerand a signed JWT whoseissandsubare the client ID, whoseaudidentifies the authorisation server and which carries anexp. The server stores only a public key. See the JWT Client Authentication (private_key_jwt) Guide. - mTLS (RFC 8705): the client authenticates with a certificate during the TLS handshake, and the same certificate can sender-constrain the access token. See the mTLS Guide.
- No stored credential: where the client runs on a cloud platform, workload identity federation can remove the long-lived credential altogether. See the Workload Identity Federation Guide.
| Client pattern | PKCE | Client authentication | Notes |
|---|---|---|---|
| Server-rendered web app | Yes, S256, per transaction | private_key_jwt or mTLS; secret only as a transitional step | Bind the verifier to the user's session |
| Backend for frontend for a single-page app | Yes, generated in the backend | Yes, the BFF is a confidential client | The OAuth 2.1 draft recommends the BFF pattern for browser apps that want credentials. See the OAuth for Browser-Based Applications and the BFF Pattern Guide |
| Native mobile or desktop app | Mandatory | None that the server should trust; dynamic registration per instance is an option | Never ship a shared secret in the binary |
| Agent or integration backend acting for a user | Yes | private_key_jwt with a key in a KMS or HSM | The client credential is a privileged NHI |
| Machine-to-machine (client credentials) | Not applicable | Asymmetric or federated | See the Machine-to-Machine OAuth (Client Credentials) Guide |
Secret sprawl: the hidden cost of "secret only"
A confidential client's secret is a non-human identity credential with every permission the application holds. Unlike a PKCE verifier, which lives for one transaction in one process, a client secret is copied into every environment that runs the application, every pipeline that deploys it and often every developer laptop that tests it. RFC 7591 even allows a registration with no secret expiry. Each copy is a place to leak from and a place rotation must reach.
- Count copies, not clients: one client secret commonly ends up in several stores. Measure it with the method in the Secret Sprawl Measurement Guide.
- Rotation is the real price: shared secrets need overlapping rotation across every copy; asymmetric keys rotate by publishing a new public key while the old one is still accepted.
- App permissions raise the stakes: a client that holds application permissions can obtain tokens with no user at all. In the Microsoft Midnight Blizzard breach attackers took over a legacy test OAuth application with elevated access and used application permissions to read executive mailboxes, a reminder that forgotten confidential clients are privileged identities. See the Client ID and Client Secret Guide for storage and leak response.
Decision criteria
- Is the grant the authorisation code grant? Yes: PKCE S256 on every request, whatever the client type. No (client credentials, token exchange): PKCE is not relevant.
- Can the client keep a credential confidential? Server-side code, a BFF or an agent backend: yes, register it as confidential and authenticate it. Code running on a user's device or in a browser: no, register it as public.
- Which credential? Prefer
private_key_jwtor mTLS; use workload identity federation where the platform offers it; keep shared secrets only where the authorisation server supports nothing else, with expiry and an owner. - OpenID Connect client relying on
nonce? It meets RFC 9700's alternative, but add PKCE anyway: the server then rejects injected codes before tokens are issued, and you no longer depend on every client validatingnoncecorrectly. - Regulated or high-value API? Follow FAPI 2.0: PKCE S256, PAR, asymmetric client authentication and sender-constrained tokens. See the OAuth Pushed Authorization Requests and JAR Guide.
Small versus large organisations: a small team using a hosted identity provider can usually switch on "require PKCE" per application and move to a certificate or key credential in the application settings. A large estate needs an inventory first, because the risky clients are the old ones nobody owns, and a phased rollout because some legacy libraries do not send PKCE.
Worked scenario: a customer portal and its reporting backend
A company runs a server-rendered customer portal and a reporting backend, both confidential clients using shared secrets stored in CI variables and copied to three environments. A review finds the portal sends state but no PKCE, and the authorisation server accepts token requests with or without a verifier.
- Turn on PKCE in the client: the portal's OAuth library is configured to send S256 challenges; the verifier is stored server-side against the session.
- Observe: the authorisation server logs, per client, whether a code challenge was present, for two weeks.
- Enforce: once 100 percent of portal requests carry a challenge, the server is set to require PKCE for that client and to reject a verifier when no challenge was sent.
- Replace the secret: each client gets a key pair generated in the cloud KMS; the public key is registered, the clients switch to
private_key_jwt, and the shared secrets stay valid for a one week overlap. - Remove the fallback: the secrets are deleted from the authorisation server and from every CI store, and the clients' allowed authentication method is restricted to
private_key_jwt. - Roll back if needed: during steps 3 and 4 the previous setting can be restored per client within minutes; after step 5 rollback means issuing a new secret, which is logged and approved.
Detection and measurement
- Token endpoint logs: record client ID, authentication method used, whether a code verifier was presented and whether the authorisation request carried a challenge.
- Alerts: code redemption failures for verifier mismatch (possible injection attempts), verifier sent without challenge (downgrade probing), token requests for a client from unexpected networks, and new credentials added to an application.
- Directory reviews: list every application credential with its type, creation date, expiry and owner.
Metrics:
- Percentage of authorisation code clients with PKCE enforced by the server (target 100 percent).
- Percentage of confidential clients using asymmetric or federated authentication instead of shared secrets.
- Number of client secrets with no expiry or no owner.
- Average number of stores holding each remaining client secret.
- Count of servers or clients still accepting the
plainmethod (target zero).
Framework mapping
| Framework | Reference | Relevance |
|---|---|---|
| NIST CSF 2.0 | PR.AA-01, PR.AA-03 | Managing client credentials; authenticating services |
| NIST SP 800-53 Rev. 5 | IA-9 Service Identification and Authentication; IA-5(2) Public Key-based Authentication | Authenticating the client application; asymmetric client credentials |
| NIST SP 800-53 Rev. 5 | SC-23 Session Authenticity; IA-5 Authenticator Management | Binding the code to the transaction; managing secret lifecycle |
| OWASP ASVS 5.0 | V10.2.1, V10.4.6, V10.4.16 | CSRF protection by PKCE or state; server-side PKCE enforcement; strong client authentication |
| MITRE ATT&CK | T1552.001 Credentials In Files; T1098.001 Additional Cloud Credentials; T1528 Steal Application Access Token | Theft and addition of client credentials; theft of tokens and codes |
| MITRE D3FEND | D3-TB Token Binding | Sender-constrained tokens complement both controls |
Common failure patterns
- "We are confidential, so we skip PKCE": leaves code injection and, without strict
statehandling, login CSRF open. - Client-side PKCE without server enforcement: the client sends a challenge but the server never requires one, so an attacker simply omits it.
- Dropping client authentication after adding PKCE: turns a confidential client into a public one in practice.
- Secrets embedded in mobile or single-page apps "as well as PKCE": the secret is extractable and adds nothing; register the app as public or use a BFF.
- Secrets that never expire and have no owner: the credential outlives the project and becomes the easiest way in.
- Allowing fallback authentication methods: a client moved to
private_key_jwtthat still accepts its old secret has not been hardened.
Practitioner checklist
- Inventory every OAuth client with its type (public or confidential), grants and authentication method.
- Require PKCE S256 on the server for every authorisation code client, including confidential ones.
- Reject verifiers sent without a challenge and refuse the
plainmethod. - Require client authentication for every confidential client; never trust secrets in public clients.
- Migrate confidential clients from shared secrets to
private_key_jwt, mTLS or workload identity federation. - Restrict each client to one authentication method and remove fallbacks.
- Give every remaining secret an owner, an expiry and a single authoritative store.
- Add sender-constrained tokens for high-value APIs, since neither control protects issued tokens.
Standards and references
- IETF: RFC 9700 Best Current Practice for OAuth 2.0 Security
- IETF: RFC 7636 Proof Key for Code Exchange by OAuth Public Clients
- IETF: The OAuth 2.1 Authorization Framework (Internet-Draft)
- IETF: RFC 7523 JWT Profile for OAuth 2.0 Client Authentication and Authorization Grants
- OpenID Foundation: FAPI 2.0 Security Profile
- OWASP: Application Security Verification Standard 5.0
- MITRE ATT&CK: Credentials In Files
- NIST: SP 800-53 Rev. 5
Related NHI Mgmt Group resources: PKCE Implementation · Client ID and Client Secret Guide · JWT Client Authentication (private_key_jwt) Guide · OAuth 2.0 Security Best Current Practice (RFC 9700) Guide