OAuth and OIDC From the Backend Side
Three roles, two very different tokens, and one rule: use a maintained library, because the parts you would get wrong are the security parts.
The requirement, the obvious build, and why it breaks
Every lesson starts where the work starts: someone asked for something, and the first implementation that comes to mind has a problem.
What does your backend actually do in an OAuth flow, and which role is it playing?
Users should be able to sign in with an existing identity provider, and the product must also be able to read a user's data from a third-party service on their behalf.
Redirect to the provider, get a code back, exchange it for a token, and store the token. It is four HTTP calls and looks like something you could write in an afternoon.
The state parameter is omitted or not checked, so an attacker can complete an authorization flow against a victim's session and link their own third-party account to the victim's — or the reverse.
- The
stateparameter is omitted or not checked, so an attacker can complete an authorization flow against a victim's session and link their own third-party account to the victim's — or the reverse. - The redirect URI is matched loosely, so an open redirect or a wildcard subdomain lets an attacker receive the authorization code.
- An access token from a social provider is used as proof of identity, and because it is a bearer token issued for API access, a token obtained by an unrelated application is accepted as a login for that user.
- The provider is used for login and the backend takes the email claim as the account key without checking whether the provider verified it, so an unverified email is an account takeover.
- Refresh tokens are stored in plaintext, and the store becomes a set of long-lived credentials to other people's services.
- The provider has an outage and the login path has no fallback, so nobody can sign in at all (Failure Propagation).
What is actually happening
- OAuth 2.x is a delegated authorization protocol. It exists so a user can let one application act on their behalf at another service without sharing a password. It is not, by itself, an authentication protocol (OAuth 2.x — Delegated Authorization in Security Engineering).
- OpenID Connect is the authentication layer on top of it. It adds an ID token — a signed set of claims about who the user is, intended for your application and validated by your application (OpenID Connect in Security Engineering).
- Your backend plays one of three roles, and the code is different for each. As a *client*, you send users to a provider and exchange codes for tokens. As a *resource server*, you accept access tokens issued by someone else and validate them. As an *authorization server*, you issue them — which is a product in itself and almost never something to build.
- The authorization code flow with PKCE is the current default for both server-rendered and public clients. The user is redirected, the provider authenticates them, a short-lived code returns to your redirect URI, and your backend exchanges that code — plus a proof it started the flow — for tokens over a direct server-to-server call (The Authorization Code Flow (with PKCE) in Security Engineering).
- `state` binds the callback to the browser session that started it. It is CSRF protection for the flow, and it is checked server-side against something you stored before the redirect.
- The two tokens are for different audiences. The ID token is for you, describing the user. The access token is for the provider's API, describing a grant. Treating one as the other is the characteristic OAuth bug (JWT — What It Is and What It Costs in Security Engineering).
- Scopes are the grant's boundary, and they are the provider's notion of permission, not yours. They tell you what the provider will let you do; they do not tell you what your own application should allow (Scopes: Least Privilege as Contract Surface in API Design, Authentication vs Authorization).
Which role is your backend playing?
Most OAuth confusion is role confusion. The same words — token, scope, client — mean different things depending on which side of the flow you are on, and the code you write is almost entirely different.
Answer this before reading any provider documentation, because the documentation is usually written for one role and does not say which.
| Role | What your backend does | What it holds | The characteristic mistake |
|---|---|---|---|
| Client (login with a provider) | Redirects the user, exchanges the code, validates the ID token, creates its own session | Client id and secret; the provider's public keys | Accepting an access token as proof of identity instead of validating an ID token |
| Client (acting on a user's behalf) | Obtains and refreshes access tokens for a third-party API | Access and refresh tokens for other people's accounts | Storing refresh tokens unencrypted; requesting far broader scopes than needed |
| Resource server | Accepts access tokens issued elsewhere and validates them before serving its own API | The issuer's public key set and the expected audience | Trusting claims without validating issuer and audience; treating scopes as the whole authorization model |
| Authorization server | Issues tokens, manages consent, hosts the endpoints | Signing keys, grants, consent records | Building it — this is a product with a long security tail |
The authorization code flow, and where each protection sits
Follow the arrows and notice which ones pass through the user's browser. Everything on the front channel is visible to the user and reachable by an attacker who controls part of the page or the redirect; only the token exchange is a direct call between your backend and the provider.
That split explains every parameter: state protects the front-channel round trip, PKCE protects the code from being useful to anyone who intercepts it, and exact redirect matching stops the code being delivered somewhere else entirely.
Two tokens, and the mistake of swapping them
The single most damaging OAuth error in application backends is using an access token as an identity assertion. It is easy to make because the flow hands you both tokens at once and because a user info endpoint will happily answer with the right user — for whichever application obtained the token.
The compare below is the whole distinction. An ID token has an audience: it names your client id, and validating that is what makes it a login for *your* application rather than for anyone who obtained a token for that user.
const { access_token } = await exchange(code)
const me = await fetch(provider + '/userinfo', {
headers: { Authorization: `Bearer ${access_token}` },
}).then(r => r.json())
const user = await users.byEmail(me.email) // trusted as login
await createSession(user)
// The token proves someone granted some app API access.
// It carries no audience for you and no proof it was
// issued to your client. An attacker who obtains a token
// for a victim from any app can present it here.const { id_token } = await exchange(code, { pkceVerifier })
const claims = await verifyIdToken(id_token, {
issuer: provider,
audience: CLIENT_ID, // it must be for *us*
nonce: storedNonce, // bound to this flow
algorithms: ['RS256'], // pinned, never from the token
jwks: providerKeys,
})
if (!claims.email_verified) throw new AuthError('unverified email')
const user = await users.byProviderSubject(provider, claims.sub)
await createSession(user) // then use your own session
// sub, not email, is the stable account key.The ID token names your client as its audience and is bound to this flow by the nonce, so a token obtained by a different application cannot be replayed at your login endpoint. The access token has neither property — it is an API credential, and it is the wrong artefact for the question "who is this".
How to build it
Most important first.
- Use a maintained, current OAuth/OIDC library or your provider's SDK. The failure modes here are subtle, well catalogued and actively exploited, and a hand-rolled client is a liability.
- Use the authorization code flow with PKCE. Generate
stateand the PKCE verifier with a cryptographic random source, store them server-side bound to the browser session, and reject any callback that does not match. - Register exact redirect URIs and match them exactly. No wildcards, no prefix matching, no user-supplied return paths appended without validation.
- For login, validate the ID token properly: signature against the provider's published keys, issuer, audience equal to your client id, expiry, nonce. Never accept an access token as proof of identity.
- Decide your account-linking rule explicitly and write it down. Matching on email requires the provider to assert the email is verified, and even then linking an existing local account to a social identity should require a step the legitimate user takes.
- Store the provider's subject identifier as the account key, not the email, because emails change and are reused.
- Encrypt refresh tokens at rest, scope access to them tightly, and have a rotation and revocation story (Secrets Are Not Configuration).
- Request the narrowest scopes that do the job, and request additional ones incrementally when the feature that needs them is used.
- Treat the provider as an external dependency with timeouts, retries where safe, and a circuit breaker on the token endpoint (Timeouts, Circuit Breakers, Calling Something You Do Not Control).
- As a resource server, validate tokens the same way — pinned algorithm, issuer, audience, expiry — and cache the provider's key set with refresh on an unknown key id (Token Authentication and the Revocation Problem).
What can go wrong
stategenerated but never verified, which is functionally the same as not having it.- PKCE implemented for the mobile client and skipped for the web client on the grounds that it has a client secret.
- ID token signature validated against the wrong key set, or not validated at all because "it came from the provider over TLS" — it came from the browser.
- Token exchange performed from the browser rather than the backend, exposing the client secret.
- The provider's user info endpoint called on every request instead of at login, turning a third party into a per-request dependency.
- Refresh failures treated as a transient error and retried in a loop, hitting the provider's rate limit and locking your application out (Retry Storms).
- Multiple providers returning the same email and silently merging into one account, or failing to merge and producing duplicates — whichever way the code fell.
- Two browser tabs starting a flow concurrently overwrite each other's stored
stateand PKCE verifier if they are kept in a single session slot; store them keyed by thestatevalue so concurrent flows do not collide. - Concurrent refreshes of the same provider refresh token race in exactly the way described in Token Authentication and the Revocation Problem, and many providers rotate on refresh, so one of the two ends up holding an invalidated token — serialise refresh per connection.
- An authorization code replayed concurrently by an attacker and the legitimate callback: single-use enforcement must be atomic, or both exchanges can succeed (Atomic Operations).
- This is a protocol where implementation details are the security. Deviating from the specified flow — skipping PKCE, loosening redirect matching, omitting nonce — removes a defence that exists for a documented attack.
- Never accept an access token as an identity assertion. It was issued to some application for some API; it says nothing about who is talking to you (OAuth 2.x — Delegated Authorization in Security Engineering).
- Validate
audon ID tokens. A valid, correctly signed ID token issued for a different client is not a login for yours. - Authorization codes are single-use and short-lived; treat a reused code as an attack and refuse it.
- The tokens you hold for third-party APIs are credentials belonging to your users. A breach of that store is a breach of every connected service (Secrets Management in Security Engineering).
- Scopes granted by a provider are not your authorization model. A token with
reposcope tells you what GitHub will permit; whether *this user* may trigger *this action* in your product is still your decision (Authorization in Backends). - Log flow outcomes without logging codes or tokens; authorization codes in an access log are usable within their lifetime.
- "OAuth is a login protocol." OAuth is delegated authorization. OIDC is the login layer built on it, and the difference is the source of the most serious mistakes in this area.
- "We got an access token, so the user is authenticated." You have a grant to call an API. Identity comes from a validated ID token or from calling an identity endpoint with that token — and the first is what OIDC exists to provide.
- "The token came over HTTPS from the provider, so it is trustworthy." Front-channel responses arrive via the user's browser. Only the direct back-channel exchange is provider-to-you.
- "PKCE is only for mobile apps." It is recommended for confidential clients too; it defends the code interception step regardless of client type.
- "Scopes are our permission model." They are the provider's. Your authorization rules are still yours to enforce (Where the Check Belongs).
- "We should build our own authorization server." Almost never. It is a product with a long security tail, and mature implementations exist.
Operating it
- Login success rate by provider, and callback failures broken down by reason — state mismatch, expired code, signature failure. State mismatches at any volume are worth investigating rather than dismissing.
- Token exchange latency and error rate as a dependency metric; the provider is on your login critical path (Calling Something You Do Not Control).
- Refresh failure rate per provider, and the count of users whose connection has gone into a permanently failed state — that number tends to grow silently.
- Key-set fetch failures and unknown-key-id events, which precede a total verification outage.
- Count logins by account-linking outcome: new account, linked to existing, refused. A spike in linking is worth a look.
- The provider becomes part of your availability for login. At any scale, that is an argument for keeping a local authentication path for at least some accounts (Failure Propagation).
- At 10x, provider rate limits become real: token exchange, user info and refresh all have quotas, and a retry loop can exhaust them for every user at once.
- At 100x, the pattern that survives is exchanging the provider's token for your own session or token at login, so the provider is touched once per login rather than once per request.
- Federated login removes password storage from your system and adds a third party to your login path, plus an account-recovery story for users who lose access to that provider.
- A library removes the subtle security work and gives you a dependency to keep current — and OAuth libraries do get security releases you must apply.
- Narrow scopes are safer and mean returning to the user for consent when a feature needs more, which some product teams will resist.
- Storing refresh tokens lets you act while the user is away and makes your database a high-value target.
Where this applies
Backend advice is context-sensitive. These labels say what each claim is specific to, and where a different stack or scale would differ.
- PROTOCOL-SPECIFICThis lesson describes OAuth 2.x with the authorization code flow and PKCE, plus OIDC for identity. Other grant types behave differently — the client credentials grant has no user and no redirect at all, and the implicit and password grants are discouraged in current guidance. The protocol also continues to evolve, so treat the current specification and your provider's documentation as authoritative over any summary, including this one.
- GENERALThe role separation — client, resource server, authorization server — and the rule that an access token is not an identity assertion hold across every provider and version.
- FRAMEWORK-SPECIFICWhat you must implement yourself varies enormously: a full framework integration (Spring Security OAuth2, Django allauth, NextAuth) handles state, PKCE, ID token validation and key rotation for you, while a bare HTTP client leaves every one of those to you. Know which of those you are in before deciding a step is "already handled".
Where the depth lives
This domain teaches the application-side mechanism and hands the rest off.