The most damaging OAuth design mistake is not a weak secret, it is picking the wrong grant, so that a service token silently does work that should have been bound to a user. The authorisation code grant issues a token that carries a user's delegated authority, after that user has authenticated and consented. The client credentials grant issues a token that carries only the application's own authority, with no user in the picture at all. The two flows look similar at the token endpoint, which is exactly why teams mix them up. This guide is a decision guide for architects, identity engineers and API owners: how each grant works in words, what each one's threat model looks like, how to choose by caller type (a user-facing app, a background service, an AI agent acting for a person), and where PKCE and token exchange change the answer. Mechanics of client credentials in depth are in the Machine-to-Machine OAuth (Client Credentials) Guide, and PKCE detail is in PKCE Implementation.
Key takeaways
- The question is whose authority the token carries. Authorisation code produces a token for a user, via a client; client credentials produces a token for the client alone. Choose by the subject of the action, not by the convenience of the integration.
- Client credentials for user actions is the classic failure. A service token that can act on any user's data turns every downstream authorisation check into "is this the trusted app?", which removes per-user limits and per-user audit.
- Authorisation code now always means PKCE. RFC 9700 says public clients must use PKCE and confidential clients should, and the OAuth 2.1 draft requires it by default. PKCE does not apply to client credentials, because there is no code.
- Client credentials is for confidential clients only. RFC 6749 section 4.4 says the grant must only be used by confidential clients, and section 4.4.3 says a refresh token should not be included in the response.
- Agents usually need both, joined by token exchange. An agent acting for a user starts from a user-delegated token and should exchange it (RFC 8693) for a narrow, audience-bound token that names the agent as actor, rather than reusing its own service credential.
The short answer
If a human is the reason the call is happening and the data belongs to, or is scoped by, that human, use the authorisation code grant with PKCE, so the access token represents the user's delegated consent and the resource server can enforce that user's permissions. If no human is involved, the caller is a backend workload acting on its own behalf (a nightly sync, a monitoring probe, a service calling another service it owns), use the client credentials grant with strong client authentication, ideally private_key_jwt or mutual TLS, and a narrow audience. If a workload acts for a specific user after the user has left, such as an agent completing a task, do not fall back to client credentials: carry the user context forward with a refresh token held by a confidential client, or exchange the user's token for a delegated one. Implicit and resource owner password credentials grants are not options: RFC 9700 says clients should not use the implicit grant and the password grant must not be used, and neither appears in the OAuth 2.1 draft.
Side-by-side comparison
| Dimension | Authorisation code (with PKCE) | Client credentials |
|---|---|---|
| Subject of the token | The resource owner (user), acting through the client | The client application itself |
| Defined in | RFC 6749 section 4.1, PKCE in RFC 7636 | RFC 6749 section 4.4 |
| User interaction | Yes: browser redirect, authentication, consent | None |
| Client types allowed | Confidential and public (public clients rely on PKCE) | Confidential only |
| Proof at token endpoint | Authorisation code plus code_verifier, plus client authentication if confidential | Client authentication only |
| Refresh token | Commonly issued; public clients need rotation or sender constraint (RFC 9700 section 2.2.2) | Should not be included (RFC 6749 section 4.4.3); the client simply asks again |
| Scope meaning | The ceiling of what the user consented to, intersected with what the user may do | The full authority granted to the application, for every record it can reach |
| Audit trail | Token carries the user's sub; actions attribute to a person | Token carries the client's identity; actions attribute to a workload |
| Main credential to protect | Redirect URIs, authorisation codes, refresh tokens | The client secret or private key |
| Typical blast radius if stolen | One user's delegated access, for the token or refresh lifetime | Everything the application can touch, for every tenant or user it serves |
Authorisation code grant: architecture and threat model
Architecture in words
The client sends the user's browser to the authorisation endpoint with its client_id, requested scope, a state value, its registered redirect_uri and a PKCE code_challenge derived with S256. The authorisation server authenticates the user, records consent and redirects back with a short-lived, single-use code. The client then calls the token endpoint with the code, the original redirect URI and the code_verifier, authenticating itself if it is confidential. RFC 7636 defines the verifier as a random string of 43 to 128 characters and makes S256 mandatory for servers to implement; the plain method exists only for compatibility.
What goes wrong
- Code interception or injection: RFC 7636 was written for malicious apps that register the same custom URI scheme and capture the code. RFC 9700 adds code injection into the legitimate client's session, which PKCE stops and client authentication alone does not. See Client Secret vs PKCE.
- Redirect URI abuse: loose matching lets an attacker steer codes to a URI they control. RFC 9700 requires exact string matching, with a port exception only for
localhostin native apps, and OWASP ASVS 5.0 V10.4.1 asks for the same check. - Refresh token theft: long-lived refresh tokens held by an integration are the real prize. In the Salesloft Drift breach stolen OAuth access and refresh tokens for a chatbot integration let the actor call Salesforce APIs as the trusted app and bulk export records across many tenants.
- Over-broad consent: a single "full access" scope means a user-delegated token is still far more powerful than the task needs.
Controls that matter
PKCE S256 on every request, exact redirect URI matching, state or PKCE for CSRF, short access token lifetimes, refresh token rotation or sender constraint for public clients (see OAuth Refresh Token Rotation and Expiry Guide), and audience restriction so a token for one API is rejected by another.
Client credentials grant: architecture and threat model
Architecture in words
A backend workload calls the token endpoint directly with grant_type=client_credentials, an optional scope, and its client authentication: a shared secret (client_secret_basic or client_secret_post), a signed JWT assertion (private_key_jwt, RFC 7523) or a client certificate (mutual TLS, RFC 8705). The authorisation server returns an access token whose subject is the client. There is no browser, no code and no consent screen, so nothing in the flow can tell the authorisation server which user, if any, the work is for.
What goes wrong
- Secret sprawl: shared secrets end up in environment variables, CI variables, config repositories and chat. Anyone who copies the secret is the application.
- App-wide authority: the token is valid for every record the app's scopes reach, so a leak is a tenant-wide or platform-wide event, not a single-user one.
- Use for user actions: the service receives a user's request, then calls a downstream API with its own client credentials token, passing a
user_idparameter. The downstream API now trusts a parameter, not a token, which is the same shape as broken object level authorisation. See BOLA vs BFLA. - No natural expiry pressure: because the client can mint a new token at will, teams set long token lifetimes or never rotate the underlying credential.
Controls that matter
RFC 9700 section 2.5 recommends asymmetric client authentication (mutual TLS or private key JWTs). Bind tokens to the client with mTLS or DPoP (RFC 9449) as RFC 9700 section 2.2.1 recommends, restrict each token to one audience, keep scopes per downstream API and per operation, and store keys in a KMS, HSM or workload identity system rather than files. Where the platform supports it, replace stored secrets with workload identity federation. Deep implementation is in the Machine-to-Machine OAuth (Client Credentials) Guide and DPoP Guide.
Choosing by caller type
| Caller | Who is the subject? | Grant | Notes |
|---|---|---|---|
| Server-side web app acting for a signed-in user | User | Authorisation code + PKCE + client authentication | Confidential client; prefer private_key_jwt over a shared secret |
| Single-page app or mobile app | User | Authorisation code + PKCE | Public client; never ship a secret. Consider a backend for frontend so tokens stay server side |
| Nightly batch job, metrics collector, service calling its own platform API | The workload | Client credentials | Asymmetric client auth, audience-bound token, minimal scopes |
| SaaS integration that reads a customer user's data | User (or the customer tenant admin who consented) | Authorisation code; refresh token held by the integration | The refresh token is a high-value NHI secret: rotate, vault and monitor it |
| Device or CLI without a browser | User | Device authorisation grant (RFC 8628) or CIBA | See CIBA vs OAuth Device Flow |
| Microservice receiving a user's token and calling another API | User, with the service as actor | Token exchange (RFC 8693) | Do not forward the inbound token; do not swap to client credentials |
| AI agent acting for a user | User, with the agent as actor | Authorisation code for initial consent, then token exchange or a narrowly scoped refresh token | Record the agent in the act claim; keep per-task scopes |
| Autonomous agent doing its own housekeeping | The agent workload | Client credentials | Treat the agent as an NHI with its own lifecycle and owner |
Small organisations: the usual first win is moving user-facing integrations off shared service accounts and onto authorisation code with PKCE, and moving client credentials secrets into a secrets manager. Large organisations: the harder problem is east-west traffic, where dozens of services use client credentials tokens with broad scopes to act for users. That needs token exchange at a central authorisation server and resource servers that read the user subject, not just the client ID.
PKCE and token exchange edge cases
- PKCE with confidential clients: still required in practice. RFC 9700 recommends it, and the OAuth 2.1 draft (draft-ietf-oauth-v2-1-17, dated 31 October 2026, still an Internet-Draft) requires it by default. Client authentication proves which app is calling; PKCE proves the code belongs to this transaction.
- PKCE with client credentials: does not apply. If a team asks "PKCE or client credentials?", the real question is whether a user is involved.
- Token exchange for delegation: RFC 8693 uses
grant_type=urn:ietf:params:oauth:grant-type:token-exchangewith asubject_token(the user's token) and optionalactor_token(the calling service or agent). With delegation the actor keeps its own identity and the issued JWT carries anactclaim naming it; with impersonation the actor becomes indistinguishable from the subject. Prefer delegation so audit shows both parties. Themay_actclaim lets the authorisation server check which actors a subject's token may be exchanged for. See OAuth Token Exchange and Delegation Guide. - Audience on exchange: request the downstream API with
audienceorresource, so the exchanged token is useless anywhere else. - Token passthrough: the Model Context Protocol authorisation specification (2025-06-18) says an MCP server must not pass through the token it received from the client to upstream APIs, and must only accept tokens issued for itself. The same rule is sound for any service in a call chain. See OAuth Token Exchange vs Token Passthrough.
- MCP and agents: the MCP specification builds on the authorisation code flow: clients must use PKCE and must send RFC 8707
resourceindicators naming the MCP server in authorisation and token requests. An agent connecting to a user's tools is a user-delegated client, not a service.
Worked scenario: an expense assistant
A company deploys an AI expense assistant. Employees chat with it, and it files claims in the finance API and reads receipts from the employee's mailbox. A first design gave the assistant a client credentials token with expenses:write and mail:read for the whole tenant, and the assistant passed employee_id in each call. A prompt injection in a forwarded receipt could have told it to read another employee's mail, and the finance API would have had no way to object.
The redesign: each employee connects once through the authorisation code flow with PKCE, consenting to mail.read.receipts and expenses:submit. The assistant's backend is a confidential client using private_key_jwt and stores the refresh token in a vault, keyed by user. For each task it exchanges the user's access token for a token with audience set to the finance API, a single scope, a five-minute lifetime and an act claim naming the assistant. The finance API authorises on the user subject, so the assistant can only file that employee's claims, and logs show the employee and the agent together. The assistant keeps a separate client credentials registration, with no user data scopes, for its own health checks.
Implementation and migration
- Inventory clients by grant: export every registered client from the authorisation server with its allowed grant types, client authentication method, scopes and owner. Flag clients allowed both
authorization_codeandclient_credentials; they are often the ones mixing user and service work. - Find client credentials tokens acting on user data: at the resource servers, look for app-only tokens (no user
sub, orsubequal to the client ID) calling endpoints that take a user or tenant identifier in the request. Each is a candidate for migration. - Enable PKCE everywhere for authorisation code: require
code_challenge_method=S256on the server, rejectplain, and block downgrade by rejecting acode_verifierwhen no challenge was sent. - Harden client credentials clients: move to
private_key_jwtor mTLS, set an audience per token, split broad scopes, and set a credential rotation schedule with an owner. - Introduce token exchange for service chains: stand up the exchange endpoint, define
may_actor policy rules for which services may act for users, and migrate one call chain at a time. - Test in shadow mode: have resource servers log what they would decide using the user subject while still accepting the old tokens, compare, then enforce. Roll back by re-enabling the legacy client grant, which you should disable rather than delete until the cut-over is proven.
- Retire the old path: remove the
client_credentialsgrant from clients that only need user flows, and revoke their secrets.
Detection and measurement
What to log: at the authorisation server, every token issuance with grant type, client ID, client authentication method, scopes, audience and, for exchange, subject and actor. At resource servers, the token's client ID, subject, scopes and the object or tenant acted on. Alert on client credentials issuance from new IP ranges or ASNs, sudden bursts of token requests, refresh token use from a new client location, and app-only tokens touching many users' records in a short window. MITRE ATT&CK describes the attacker side as T1528 Steal Application Access Token and T1550.001 Use Alternate Authentication Material: Application Access Token.
| Metric | Target |
|---|---|
| Share of authorisation code requests using PKCE S256 | 100% |
| Client credentials clients using asymmetric client authentication | Rising each quarter, towards 100% |
| App-only tokens observed calling user-scoped endpoints | Falling towards zero, each one with a documented exception |
| Client credentials clients with a named owner and rotation date | 100% |
| Access tokens accepted without an audience check | Zero |
| Median client secret age | Within the rotation policy |
Framework mapping
| Framework | Reference | Relevance |
|---|---|---|
| NIST SP 800-53 Rev 5 | AC-3 Access Enforcement; AC-6 Least Privilege | Enforce the user's permissions, not the service's, for user actions; narrow scopes |
| NIST SP 800-53 Rev 5 | IA-9 Service Identification and Authentication; IA-5 Authenticator Management | Client authentication and secret or key management for client credentials |
| NIST CSF 2.0 | PR.AA-01; PR.AA-05 | Manage identities and credentials for services; least privilege in authorisations |
| OWASP ASVS 5.0 | V8.3.3; V10.4.1 | Access based on the originating subject, not an intermediary; exact redirect URI matching |
| MITRE ATT&CK v20 | T1528; T1550.001 | Theft and reuse of application access tokens |
Common failure patterns
- Service token plus user ID parameter: the downstream API trusts a parameter for the user, so any caller holding the service token can act for anyone.
- Client credentials in a public client: a mobile or browser app ships a client secret to call an API "as the app". The secret is extractable, and RFC 6749 limits the grant to confidential clients.
- Forwarding the user's token downstream: the same token works at every API in the chain because no audience is checked, so one leak opens all of them.
- Agents on shared service credentials: an agent acting for many users with one client credentials token cannot be limited to the current user and leaves no per-user audit trail.
- Refresh tokens treated as low value: integration refresh tokens stored in plaintext databases and never rotated, giving long-term user-delegated access to whoever steals them.
Practitioner checklist
- For each client, write down whose authority its tokens carry, and confirm the grant matches.
- Require PKCE S256 for every authorisation code client, public or confidential, and reject
plain. - Allow the client credentials grant only for confidential clients with no user-scoped work.
- Move client credentials clients to
private_key_jwtor mTLS and give each an owner and rotation date. - Restrict every access token to one audience and validate
audat every resource server. - Replace token forwarding and service-token-plus-user-ID patterns with RFC 8693 token exchange.
- Give AI agents user-delegated tokens for user work, with the agent named as actor, and separate client credentials for their own housekeeping.
- Vault, rotate and monitor refresh tokens held by integrations.
- Log grant type, client, subject, actor, scopes and audience on every issuance and alert on anomalies.
Standards and references
- IETF: RFC 6749 The OAuth 2.0 Authorization Framework
- IETF: RFC 9700 Best Current Practice for OAuth 2.0 Security
- IETF: RFC 7636 Proof Key for Code Exchange by OAuth Public Clients
- IETF: RFC 8693 OAuth 2.0 Token Exchange
- IETF: The OAuth 2.1 Authorization Framework (Internet-Draft)
- Model Context Protocol: Authorization specification
- NIST: SP 800-53 Rev 5 Security and Privacy Controls
Related NHI Mgmt Group resources: Machine-to-Machine OAuth (Client Credentials) Guide, Access Tokens vs Refresh Tokens, CIBA vs OAuth Device Flow, AI Agent Authorisation Guide, OAuth Scope Design Guide.