OAuth 2.0 and OIDC for Backend Engineers: Flows, Tokens, and Common Mistakes
OAuth 2.0 and OIDC explained for backend services. Learn code flow with PKCE, client credentials, token checks, and the mistakes that leak access in production.
OAuth 2.0 and OIDC matter because a wrong flow hands your API a token you cannot trust. When you skip checks on issuer, audience, or expiry, any stolen bearer string becomes a user. In my experience, the bug was an id token used as an access token. Therefore, you should pick one flow, validate every claim, and keep client secrets on the server.
What these protocols are and why they fail
OAuth 2.0 is a way to grant limited access without sharing a password. A client sends the user to an authorization server, or it asks for a token with its own credentials. Then a resource server accepts the access token on an API call. RFC 6749 defines the roles and the core grants.
OIDC adds a sign-in layer on top of OAuth. The authorization server also issues an id token about the user. OpenID Connect Core specifies that token and the discovery document.
Also, the id token is for the client, not for your downstream API. If you forward it as an access token, audience checks fall apart.
Production fails when teams mix the tokens or skip the code flow. A common mistake I have seen is the implicit flow in a browser app, which puts the token in the URL fragment. Because the fragment can leak through logs and referrers, that flow is a poor default. Still, old samples keep it alive.
Another failure is a public client with a client secret baked into a mobile binary. Anyone can extract it. When that secret can mint tokens for any user, the app is a key to your API. Consequently, public clients should use the code flow with PKCE and no embedded secret.
Architecture and how you implement it
Four parties show up in a normal user flow. The user, the client, the authorization server, and the resource server. First, the client builds an authorization URL with a client id, a redirect URI, a scope, a state, and a code challenge.
Next, the user signs in and consents. Then, the server redirects to your callback with a code. Finally, the client trades the code for tokens on a back channel.
PKCE binds that code to the client that started the request. The client sends a verifier, and the server checks it against the challenge. RFC 7636 defines the method.
Since a stolen code is useless without the verifier, PKCE belongs on public clients and on server clients too. Also, the state value should be random so a forged callback cannot start a session.
Service-to-service calls should use the client credentials grant when there is no user. The client authenticates with a secret or a private key and receives an access token. Map that client to IAM roles for backend services so the token lines up with a real permission set. If the client secret lives in git, move it with secrets management for backend systems.
What to check on every token
Validate the signature against the current keys from the issuer. Check the issuer, the audience, the expiry, and the not-before time if it is present. Specifically, the audience must be your API, not a neighbor API that shares the same issuer.
Although a library can do this, you must pass the expected issuer and audience in config. Furthermore, reject the none algorithm and reject keys the token itself supplies.
An access token may be a JWT or an opaque string. If it is opaque, call the introspection endpoint or the userinfo endpoint that your issuer documents. If it is a JWT, still check it the same way you would any other signed token. As a result, you should read JWT rotation and revocation before you cache signing keys forever.
Refresh tokens are more powerful than access tokens. Store them on the server, rotate them when the issuer sends a new one, and detect reuse. When a refresh token is replayed, revoke the family. Meanwhile, do not put refresh tokens in browser local storage.
Redirects, scopes, and discovery
Register exact redirect URIs. A wildcard redirect is an open door. If an attacker can choose the redirect, they can catch the code.
Before you add a new URI, confirm it is yours and that it uses HTTPS. After a domain change, delete the old URI.
Scopes should name real capabilities, not one giant “all” scope. The resource server should enforce the scope, not only the gateway. Also, OIDC scopes such as openid, email, and profile control the id token. They do not, by themselves, grant write access to your billing API.
Use the discovery document to find the token endpoint and the key endpoint. Do not hard-code a key set you copied last year. Since issuers rotate keys, your client should refresh the set on a schedule and on a signature failure. In addition, pin the issuer URL you expect so a look-alike host cannot swap the document.
Trade-offs among grants
The grant is the shape of the request. Pick it from the client type, not from the shortest sample. Overall, code plus PKCE and client credentials cover most backend work.
| Grant. | User present. | Client secret. | Main risk. | When it fits. |
|---|---|---|---|---|
| Code with PKCE. | Yes. | Optional. | Open redirect. | Web, mobile, and SPA. |
| Client credentials. | No. | Yes or a key. | Stolen client key. | Service to service. |
| Device code. | Yes, on another screen. | Public client. | User approves the wrong device. | TVs and CLIs. |
| Refresh token. | Later. | Depends. | Replay and theft. | Long-lived sessions. |
The code flow keeps the token off the browser URL. Client credentials are simple, and they identify a service, not a person. The device flow is for inputs that cannot show a normal redirect. The refresh grant extends a session, and it needs reuse detection.
Do not use the resource owner password grant. It trains users to type a password into your app, and it breaks multi-factor sign-in. First, move interactive apps to code plus PKCE.
Next, move workers to client credentials. Then, shorten access token life. Finally, add refresh rotation where a session must last longer than the access token.
Pitfalls and failure modes
Token leaks travel through logs, browser history, and support tools. If you put an access token in a query string, every proxy may store it. Also, a loose CORS policy can let a random site call your API with a victim browser. While you test locally, do not leave a wildcard origin in prod.
- Use code flow with PKCE for user sign-in.
- Keep client secrets and refresh tokens on the server only.
- Check issuer, audience, expiry, and signature on every call.
- Register exact redirect URIs and reject the rest.
- Rotate refresh tokens and revoke the family on reuse.
- Send tokens only over TLS, never in URLs.
Clock skew causes false rejects. If your API clock is ahead of the issuer, fresh tokens look expired. Therefore, allow a small skew and monitor NTP.
In an illustrative production range, a skew window of one or two minutes is common. Larger windows weaken expiry.
Mix-ups between APIs are common when one issuer serves many audiences. A token for the mail API must not work on the payments API. Consequently, set a distinct audience per API and reject the rest. We once hit a bottleneck when a shared cache of “valid tokens” ignored audience and served the wrong service.
Transport still matters. Terminate TLS in a place you control, and read TLS for backend engineers so the token is not visible on the wire. If internal hops are plain text, use encryption in transit for service calls. Specifically, the back channel token request must be HTTPS.
A practical code exchange
The client starts with a random state and a PKCE verifier. It sends only the challenge to the authorization endpoint. When the callback arrives, it checks state before it trusts the code. Thus, a cross-site callback fails closed.
POST /oauth/token HTTP/1.1
Host: login.example.com
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code&
code=AUTH_CODE&
redirect_uri=https://app.example.com/callback&
client_id=billing-web&
code_verifier=ORIGINAL_VERIFIER
# Resource server checks, in order:
# signature, issuer, audience, expiry, scopeThe example host is a placeholder, not a vendor URL. Your real token URL comes from discovery. If the client is confidential, also send a client secret or a private-key assertion on this call. After you receive tokens, store the refresh token in a server session, not in the browser.
On each API call, read the bearer header and run the checks in the comment. Also, map the subject to your user id only after the signature is valid. Since a forged header is cheap, never trust a user id that arrives beside an invalid token. Before you cache a decision, include the audience and the expiry in the cache key.
Performance, scale, and cost
Token checks should be local once you have the signing keys. If every request calls the introspection endpoint, you add a network hop and you can overload the issuer. Therefore, prefer signed JWTs for high volume APIs, and cache introspection for opaque tokens with a short TTL. The TTL must not outlive the token.
Key refresh is cheap if you do it on a timer. If you download the key set on every request, you will create a self-inflicted outage when the issuer blips. Consequently, cache keys, honor a reasonable max age, and refresh early. Meanwhile, a single flight refresh avoids a stampede when the cache expires.
Cost sits in the identity vendor bill, in session storage, and in incident time. Extra scopes and extra claims make tokens larger, which costs bandwidth at high QPS. In an illustrative production range, access tokens of a few kilobytes are already worth trimming. Specifically, drop unused profile claims from API tokens.
Scale the resource server, not a central session table, when tokens are self-contained. However, pure stateless tokens are harder to revoke. Balance that with a short access token life. As a result, most revokes can wait for expiry, and rare emergencies can use a denylist.
Key Takeaways
- Use the code flow with PKCE for users, and client credentials for services.
- Treat id tokens and access tokens as different objects.
- Check issuer, audience, expiry, and signature every time.
- Store client secrets and refresh tokens only on the server.
- Pin redirect URIs and reject wildcard callbacks.
- Cache issuer keys, and refresh them without a thundering herd.
- Keep access tokens short so revocation stays simple.
FAQ
Can a single page app keep a client secret?
No. Anything in the browser or the mobile binary is public. When the app is public, use PKCE and do not issue a client secret. Also, keep the refresh token on a backend session if you need one.
Should the API accept an id token?
No. The id token audience is the client, not the API. If you accept it, a token minted for the web app may be replayed against the API. Therefore, require an access token whose audience is the API.
How short should an access token live?
Short enough that waiting for expiry is an acceptable revoke. Many APIs use minutes, not days. If the client holds a refresh token, a short access token is easy. However, a very short life increases load on the token endpoint, so measure that hop.
Do we still need sessions?
Often yes, on the web client. The session can hold the refresh token and your own cookie. The API can stay stateless and check access tokens. Since the cookie and the bearer token are different, lock down both.
Draw your real clients and pick one grant for each. Then enforce PKCE, exact redirects, and audience checks in the resource server. Next, move client secrets into a vault and shorten access token life. Finally, add a test that replays an id token and expects a reject.
Last updated on 11 September 2026.