OAuth 2.1 and OIDC, Explained Properly
OAuth is delegation, not authentication. Most identity bugs start with that confusion. Here is the mental model, the flows, and the validation checklist that actually hold up.
By Innovation T Team
OAuth is a delegation protocol, not an authentication protocol. That single misunderstanding sits behind half the identity bugs we find in security reviews. If your team ships login buttons, API tokens, or machine to machine services, the next ten minutes will save you an incident.
Unlearn the wrong mental model
OAuth 2.x answers exactly one question: is this application allowed to call this API, possibly on behalf of a user. It says nothing reliable about who that user is. OpenID Connect (OIDC) is the identity layer bolted on top: it adds an ID token, a UserInfo endpoint, and strict rules for how a client learns that an authentication event actually happened.
The distinction is not academic. If your backend treats "I received an access token" as "this user is authenticated", any application that legitimately obtained a token for that user can replay it against your API and impersonate them. This access token replay problem is the exact reason OIDC exists. Delegation and authentication are different claims, and they need different tokens.
Keep this sentence taped to the wall: OAuth is about what an app may do, OIDC is about who the user is.
What OAuth 2.1 actually changes
OAuth 2.1 is still an IETF draft, but treat it as the baseline. It is OAuth 2.0 with a decade of security lessons folded in, most of them formalized earlier in RFC 9700, the OAuth Security Best Current Practice. Building to 2.1 today just means doing 2.0 correctly.
The concrete changes:
- The implicit grant is dead. Tokens delivered in URL fragments leaked through browser history, referrer headers, and logging proxies. There was no way to fix it, so it was removed.
- The resource owner password grant is dead. Any flow that teaches users to type their password into a third party app is a phishing training program.
- PKCE is mandatory for every authorization code flow, confidential clients included. It kills authorization code interception and adds CSRF protection for free.
- Redirect URIs must match exactly. No wildcards, no prefix matching, no "anything under this subdomain".
- Refresh tokens for public clients must be one time use (rotation) or sender constrained.
- Bearer tokens in query strings are banned. They end up in access logs forever.
If your identity provider or your own implementation violates any of these, that is your remediation backlog, in priority order.
The moving parts, precisely
Four roles:
- Resource owner: the human who owns the data.
- Client: the app requesting access (SPA, mobile app, backend service).
- Authorization server (AS): issues tokens after authenticating the user and recording consent. Keycloak, Auth0, Entra ID, Cognito, Zitadel.
- Resource server (RS): your API, which accepts and validates access tokens.
Three tokens, with very different jobs:
- Access token: a credential for the resource server. Short lived. The client should treat it as an opaque string, even when it happens to be a JWT.
- Refresh token: a long lived credential the client exchanges for new access tokens without bothering the user. The most valuable thing an attacker can steal.
- ID token (OIDC only): a JWT addressed to the client, not to any API. It asserts "this user authenticated at this time, via this method". Its audience is your client_id.
Two rules prevent entire bug classes:
- ID tokens never cross an API boundary. They are consumed by the client and then their job is done.
- Clients never make decisions by parsing access tokens. The token's contents are a contract between the AS and the RS.
The flows that matter in 2026
You need four. Everything else is legacy.
Authorization code with PKCE
The default for anything interactive: web apps, SPAs, mobile. The client generates a random secret (the verifier), sends its SHA-256 hash (the challenge) with the authorization request, and proves possession of the verifier when redeeming the code. An attacker who intercepts the code cannot redeem it.
const verifier = base64url(crypto.getRandomValues(new Uint8Array(32)));
const digest = await crypto.subtle.digest(
"SHA-256", new TextEncoder().encode(verifier)
);
const challenge = base64url(new Uint8Array(digest));
The authorization request should always carry response_type=code, code_challenge with code_challenge_method=S256, a state value you verify on return, and, for OIDC, scope=openid plus a nonce that you check inside the ID token. Skipping state or nonce because "PKCE covers it" is a common shortcut. PKCE covers most of it. Defense in depth costs you two random strings.
Client credentials
Machine to machine, no user involved: a billing service calling an invoicing API, a cron job pulling reports. The client authenticates with its own credentials and gets a token scoped to itself. Two disciplines matter here: request a token for a specific audience, never a universal one, and keep the client secret in a vault with rotation, not in an environment file committed "temporarily".
Device authorization grant
For input constrained devices: smart TVs, CLIs, kiosks. The device shows a short code, the user approves on their phone, the device polls for the token. If you have ever typed a code into github.com/login/device, you have used it.
Token exchange
RFC 8693, for service chains. Service A receives a user's token and needs to call service B on that user's behalf. The anti-pattern is forwarding the original token through five services, each accepting a token that was never meant for it. Token exchange lets A trade the inbound token for a new one, scoped to B, with the delegation chain recorded in the act claim. In our experience this is the piece most microservice estates are missing, and it is why one stolen token so often unlocks an entire platform.
Validate tokens like you mean it
Access tokens come in two formats. Opaque tokens require a call to the introspection endpoint (RFC 7662): more latency, instant revocation. JWTs validate locally against the AS's published keys (JWKS): fast, but revocation only takes effect at expiry. The standard compromise is JWT access tokens with a lifetime of 5 to 15 minutes plus refresh rotation, and introspection reserved for high value operations.
Local JWT validation is where audits get bloody. Run this checklist on every resource server, every time:
- Fetch signing keys from the JWKS endpoint, select by
kid, and cache with a sane TTL. Never hardcode keys. - Pin an algorithm allowlist. Expect
RS256orES256, reject everything else. This killsalg: noneand the classic RS256 to HS256 key confusion attack in one line. - Check
issagainst the exact issuer URL. Https, no trailing slash surprises. - Check that
audcontains your API's identifier. A valid token for someone else's API is not a valid token for yours. - Enforce
expandnbfwith a small clock skew, 60 to 120 seconds. - For ID tokens, additionally verify the
noncematches what you sent, andazpwhen multiple audiences are present. - Then authorize. Signature validity means the AS issued it. It does not mean this caller may delete this record. Check scopes and roles per endpoint.
In ASP.NET Core, most of this collapses into configuration:
options.TokenValidationParameters = new TokenValidationParameters
{
ValidIssuer = "https://id.example.com",
ValidAudience = "api://orders",
ValidAlgorithms = new[] { "RS256" },
ClockSkew = TimeSpan.FromSeconds(60)
};
Equivalent settings exist in jose for Node, spring-security-oauth2-resource-server for Java, and authlib for Python. Use a maintained library. Hand rolled JWT parsing is how alg: none incidents happen. For the broader hardening picture around your endpoints, see our guide to API security best practices.
Where tokens live in the browser
The uncomfortable truth: there is no fully safe place for tokens in browser JavaScript. localStorage survives XSS exactly as long as it takes to exfiltrate it. In-memory tokens are better but die on refresh and still fall to a script injection that hooks your HTTP client.
The pattern we recommend for anything serious is Backend for Frontend (BFF). The OAuth dance happens server side. Tokens never reach the browser at all. The SPA gets an HttpOnly, Secure, SameSite cookie tied to a server session, and the BFF attaches the access token to upstream calls. XSS can still ride the session while the page is open, but it can no longer steal a refresh token and walk away with persistent access.
Wherever refresh tokens live, rotate them. Every refresh issues a new refresh token and invalidates the old one. If a previously used token is ever presented again, that is your theft signal: revoke the whole token family and force reauthentication. Most mature providers support this out of the box, but it is off by default more often than you would expect. Sender constrained tokens via DPoP take this further by binding tokens to a client held key, and support has been growing steadily.
Failure modes we keep finding
- ID tokens used as API credentials. The RS accepts any JWT with a valid signature and never checks
aud. Fix: audience checks everywhere. - Loose redirect URI matching. A wildcard plus one open redirect on any matching subdomain equals stolen authorization codes.
- Missing
stateandnonce. Login CSRF and session fixation, quietly exploitable for years. - One god token for every service. No per-audience tokens, no token exchange. One compromised pod can call anything. This is precisely the failure that Zero Trust architecture is designed to contain.
- 24 hour access tokens with no revocation story. When a laptop is stolen, "wait until tomorrow" is not an incident response plan.
- Mobile apps using custom URI schemes for redirects. Any installed app can register the same scheme and intercept the code. Use claimed https redirects: App Links on Android, Universal Links on iOS.
- Client secrets shipped inside SPAs and mobile binaries. Public clients cannot keep secrets. That is what PKCE is for.
Build, buy, or self-host
Never build your own authorization server. The protocol surface (token endpoints, consent, key rotation, session management, revocation) is enormous and hostile. The real decision is managed versus self hosted:
- Managed (Auth0, Cognito, Entra External ID): fastest to production, strong defaults, per active user pricing that typically feels cheap early and painful at scale. In our experience the pricing conversation usually starts somewhere in the tens of thousands of monthly active users.
- Self hosted (Keycloak, Zitadel, Ory Hydra, Authentik): full control, data residency, no per user fees. You own upgrades, hardening, availability, and key management. Budget real engineering time, not a weekend.
Whatever you choose, insist on: OIDC certification, PKCE and refresh rotation support, per API audiences, short token lifetimes you control, and WebAuthn support, because password login is on its way out. If passkeys are on your roadmap, and they should be, OIDC remains the delivery mechanism: our post on passkeys and passwordless authentication covers how the pieces fit.
The protocol is not the hard part anymore. The discipline is: exact redirects, mandatory PKCE, audience restricted tokens, short lifetimes, rotation with reuse detection, and validation checklists enforced in code review. Teams that internalize those six habits simply stop having OAuth incidents.
How Innovation T can help
Innovation T designs and builds identity infrastructure for a living: OIDC integrations, BFF architectures for SPAs, Keycloak and cloud IdP deployments, token exchange for microservice estates, and audits of existing OAuth implementations against the 2.1 baseline. We have seen the failure modes above in the wild, and we know how to close them without breaking your users' sessions.
If you are choosing an identity provider, untangling a legacy implicit flow, or hardening an API estate, explore our services or talk to our team. We will tell you plainly what to fix first.
Ready to build with Innovation T?
Whether it is security, growth or engineering, our team can help you ship it well.