OAuth has a problem, more specifically, a comprehension problem. People throw around “OAuth token” like it’s a single thing, when actually there are multiple token types in the OAuth ecosystem, each with different structures, lifetimes, and intended uses. It’s like saying you have a card. Ok, what kind of card? Credit card? Debit card? ID card? Hell, a membership card? All of these cards mean different things and have vastly different uses. The same goes for OAuth tokens.
So let’s shed some light on this.. This is the OAuth token deep dive I wish I read when I was first untangling this stuff.
First: OAuth Is Not an Authentication Protocol
I have to say this before anything else because it’s the most common misconception of this whole thing.
OAuth is an authorization framework. It was designed to let a resource owner (a user, a bot, an agent) grant a third party (a client) scoped access to a resource, without having to hand over basic credentials like a username and password. That’s it. The original OAuth 2.0 spec (RFC 6749) doesn’t even say a single word about who the user is. It’s about access, not identity.
The OpenID Connect (OIDC) layer is a layer built on top of OAuth 2.0 and that is what gives you the identity. OIDC adds the id_token, the UserInfo endpoint, and the semantics around authenticating a user. OAuth 2.0 alone is not authentication. If you’re using an access token to figure out who someone is, you’re doing it wrong. And you’d be amazed about how many production systems are doing exactly that.
Ok. So. Tokens.
The Token Types
There are 3 main token types you’ll encounter in the OAuth 2.0 ecosystem. They’re not interchangeable, they don’t have the same trust model, and confusing them is a very common thing we see with app developers and security teams.
1. Access Tokens
This is the one everyone knows and loves. The access token is the credential the client presents to the resource server to prove it’s authorized to do something. It’s the “I’m allowed to come in” ticket. It’s your speakeasy password. It’s the key to unlock the car.
However, access tokens are not standardized in format. RFC 6749 doesn’t mandate what an access token has to look like. It can be an opaque random string, a structured JWT, or technically any other format that the authorization server and resource server both agree on. That’s both a good and bas thing becuase it makes this standard flexible, but also dangerous because it means every vendor can do it differently.
Opaque access tokens are just random strings. They’re a handle that the resource server has to validate by calling back to the authorization server (token introspection, per RFC 7662). Do not try to decod these using jwt.io. It comes back as jibberish. These are simple, easy to revoke, and reveals nothing to the client, but the downside is that validation requires a network callback to the authorization server to get the details. Not great for airgapped environemnts and other edge cases.
JWT access tokens (RFC 9068 finally standardized these) are self-contained. The resource server can validate them locally by checking the signature, the issuer, the audience, and the expiry without having to phone-home required. These tokens are fast, scalable, and the dominant choice in modern deployments. The tradeoff though is that they’re not trivially revocable. Once you issue a JWT access token, it’s valid until it expires, unless you implement a token revocation list or keep the lifetimes short. With opaque tokens, any and evey call back will show the app that the token is expired.
In best practice, access tokens should be scoped and short-lived. If your access tokens are valid for any longer than they need to be, you have a problem.
2. Refresh Tokens
Refresh tokens exist because access tokens should be short-lived and constantly making the user re-authenticate every X hours or minutes is a terrible user experience. The refresh token is a longer-lived credential that the client can use to get a new access token without user interaction.
A couple things people get wrong about refresh tokens:
They are always opaque. There’s no standard JWT format for refresh tokens, and making them self-contained completley defeats the purpose. In this case, you want the authorization server to see a refresh token exchange so it can decide whether to honor, do step up auth, or revoke it. Rotating refresh tokens (RFC 6749 recommends this; RFC 7009 enables revocation) means the AS has full visibility into the token chain.
They go to the client, not the resource server. This one is a pretty common implementation mistake. Remember that the refresh token is just a credential against the authorization server. It should never get sent to your API. If your resource server is seeing refresh tokens, something is architecturally wrong.
Refresh token lifetime is a policy decision that balances security against user friction. Offline access scopes, like offline_access in OIDC, are the mechanism for explicitly requesting a refresh token when the use case justifies it. It’s up to the IdP to honor the request or not.
3. ID Tokens
ID tokens are OIDC, not OAuth. But since OIDC is built on OAuth 2.0 and they show up in the same flows, they get lumped together constantly.
The ID token is a JWT that contains claims about the authenticated user — sub, iss, aud, exp, iat, and optionally things like email, name, phone_number depending on the requested scopes. It’s a signed assertion from the identity provider saying “I authenticated this user, here’s some stuff that I know about them.”
The ID token is for the client, not the resource server. Counter to access tokens, the ID token tells the client application who just logged in. It is not meant to be forwarded to a backend API as a credential. The ID token is consumed by the relying party, validated locally, and then you move on with life.
Mixing ID tokens and access tokens is a sign that someone got 2 tokens and took a guess as to which one to use. The mental model is: OIDC gives you the ID token to establish identity in the app at login, OAuth gives you the access token to authorize API calls going forward.
4. Authorization Codes
Technically authorization codes are not a “token” in the carry-it-around and use it sense, but they’re close enough that they need to be mentioned. The authorization code is the short-lived, single-use value that the IdP hands back to the client after the user consents. The client then takes that code and immediately exchanges it for tokens at the IdP’s token endpoint.
The reason this intermediate step exists is… Security. In the Authorization Code flow, the actual tokens never travel through the browser’s redirect. They instead come back on a direct backchannel call from the client to the token endpoint. This keeps tokens out of browser history, referrer headers, and server logs.
Authorization Code + PKCE (RFC 7636) is now the recommended flow for basically every client type, including native apps, SPAs, and traditional web apps. PKCE closes the gap for authorization code interception attacks, where a malicious app on the same device can intercept the redirect. If you’re still using the Implicit flow for SPAs (tokens directly in the redirect URI fragment), that was deprecated in the OAuth 2.0 Security Best Current Practice (RFC 9700) and you should update your flows.
Grant Types: Different Flows, Same Token Types
Now that we have level set on what OAuth tokens are, we need to skim over token types. There will be posts later going into detail on each one of these, but for now, an overview will have to do. This post is getting too long anyway. The idea is that the token types above are constant, but what changes is how you get them, which is what grant types are all about.
Authorization Code + PKCE - User-facing apps. User authenticates at the AS, app gets a code, and exchanges for tokens. The right default for anything a human logs into.
Client Credentials - Machine-to-machine. No user involvement. Service authenticates with client ID + secret (or mTLS, or private_key_jwt) and gets an access token that’s scoped to what that service is allowed to do. This is how your microservices should be talking to each other.
Device Authorization Grant (RFC 8628) - For input-constrained devices (smart TVs, CLIs, IoT). Device A will show a code and a URL, the user authorizes on a separate device B, device A polls until it gets a response. This is elegant when the specific use case fits.
Token Exchange (RFC 8693) - This one is less well-known and does a lot of the heavy lifting in complex enterprise scenarios. You present an existing token (could be a SAML assertion, a JWT from another IdP, an existing access token) and the authorization server issues a new token in a different context. Impersonation, delegation, workload federation all lives in this spec (RFC 8693). If you’re doing cross-domain identity or building service mesh authorization patterns, learn this one.
Refresh Token Grant - Already covered above. Client sends a refresh token, gets back a new access token (and usually a new refresh token if rotation is enabled).
The Implicit and Resource Owner Password Credentials grants exist in the spec, but are less secure and considered legacy. They should be considered retired for new implementations.
The Things People Still Get Wrong
Since this is a deep dive and not just a tutorial, let’s talk about a few failure modes.
Audience validation. A JWT access token contains an aud claim saying who it’s intended for. Your resource server should always validate that it’s in that audience. If you accept tokens issued for any audience, you’re vulnerable to token misuse across services. This is basic, it’s in the spec, and becasue it’s not built into the tooling, it gets skipped constantly.
Scope is not authorization. Scopes tell you what the client is allowed to access, but they can only get so specific. Because of that, they don’t replace your application’s own authorization logic. A token with read:documents shouldn’t mean the user can read every document. That’s still your problem to solve at the application layer.
Implicit trust in access tokens from your own IdP. If your authorization server issues tokens to multiple clients, your resource server shouldn’t just trust any valid token, it should validate issuer, audience, scope, and expiry. At every single point. Defense in depth at the token validation layer matters.
Long-lived tokens as a crutch. If your access tokens last 24 hours because “otherwise the user experience is bad”.. Then I don’t like you. Use refresh tokens. That’s what they’re there for. Fix the flow.
Storing tokens badly on the client. localStorage is never a safe place for tokens on the web. You should be putting them in HttpOnly cookies for session management, or in-memory token storage with a backend token handler pattern for SPAs. The threat model here is XSS. Don’t make it easy.
TL;DR
- OAuth 2.0 is not authentication. Authorization only. OIDC is the identity layer.
- There are four token types: access tokens (prove authorization to a resource server), refresh tokens (get new access tokens without re-auth), ID tokens (tell the client app who logged in), and authorization codes (one-time exchange values, not really tokens but close enough).
- Access tokens can be opaque or JWT. JWTs are faster to validate; opaque tokens are easier to revoke. Choose based on your architecture, not convenience.
- OAuth is everywhere now - SSO, mobile auth, API gateways, M2M, IoT, workload identity, cloud IAM federation. The framework is general enough that it’s become the default trust language for modern distributed systems.
- The common mistakes are boring but real: skip audience validation, treat scopes as authorization, or issue 24-hour tokens and you’ve undermined the whole model.