OAuth 2.0 is the most deployed authorization protocol in existence. Every major cloud provider supports it. Every SaaS product uses it. And most practitioners treat access tokens like magic credentials — opaque strings that, once obtained, grant access.
The spec (RFC 6749) doesn’t actually say what an access token looks like. It says the token is an opaque string that the resource server understands. That’s it. Everything else — the JWT format, the claims, the signature, the expiration — is convention, not mandate.
This gap between spec and practice is where security issues live.
What an Access Token Is
An access token is a reference to a set of permissions granted by an authorization server. The token itself doesn’t necessarily contain those permissions — it’s a key that the resource server uses to look them up.
Client → Authorization Server: "Give me access to user_profile.read on behalf of alice"
Auth Server → Client: "here's a token: eyJhbGci..."
Client → Resource Server: "GET /user/me with Authorization: Bearer eyJhbGci..."
Resource Server → Auth Server: "Is this token valid? What's its scope?"
Auth Server → Resource Server: "valid, scope=user_profile.read user_profile.write, expires 2026-07-29T12:00:00Z"
Resource Server → Client: "Here's alice's profile (read-only)"
Three things the token carries:
- Identity of the subject (who granted it)
- Scope (what permissions it covers)
- Lifespan (when it expires)
Everything else is implementation detail.
What an Access Token Is NOT
Not a session cookie. Sessions track state on the server — login status, cart contents, CSRF tokens. Access tokens are stateless references. The resource server doesn’t know if you’re “logged in” — it just knows whether the token it received is valid and what scope it has.
Not always a JWT. The RFC is explicit: an access token can be any string the resource server understands. Many implementations use JWTs (JWS-signed), but OAuth 2.0 predates JWT. Some providers use database-backed opaque tokens. Both are valid.
Not proof of authentication. OAuth 2.0 is authorization, not authentication. The token says “this client can do X on behalf of Y.” It doesn’t say “Y just authenticated.” For authentication, you need OIDC (OpenID Connect), which adds an id_token (JWT) containing claims like sub, iat, and auth_time.
Not inherently secure. A token is only as secure as its transport, storage, and validation. An unencrypted token in a URL query parameter is less secure than a token in an HttpOnly; Secure cookie — but both are valid.
The JWT Convention (And Its Pitfalls)
Most OAuth implementations use signed JWTs (JWS) as access tokens. This means the token does contain its own claims, and the resource server validates the signature rather than calling back to the auth server.
A typical access token JWT looks like this:
{
"alg": "RS256",
"typ": "JWT",
"kid": "key-2026-07",
"iss": "https://auth.example.com",
"sub": "alice@example.com",
"aud": "https://api.example.com",
"exp": 1753780800,
"iat": 1753777200,
"scope": "user_profile:read user_profile:write orders:read",
"azp": "client-app-id"
}
The claims that matter for security:
| Claim | Purpose | Security Impact |
|---|---|---|
iss | Who issued this token | Validate against trusted issuers; prevents accepting tokens from any OAuth server |
aud | Who is this token for | Prevents token reuse across services; a token for api.example.com is invalid at admin.example.com |
exp / iat | Expiration and issued-at | Short-lived tokens limit the damage window; clock skew tolerance typically 30-60s |
scope | Permissions granted | Resource server enforces scope — even if the token is valid, it may not authorize the action |
sub | Subject identifier | Identifies the user; but validation is up to the resource server |
azp | Authorized party (client) | Prevents a client from using another client’s token; critical in multi-tenant SaaS |
Common JWT validation mistakes:
alg: noneacceptance — some libraries accept tokens withalg: noneand treat them as unsigned. An attacker sends{alg: none}{payload}and bypasses signature verification entirely. Always whitelist expected algorithms.issnot validated — accepting tokens from any issuer means your service trusts any OAuth server. In a multi-tenant environment, tenant A’s tokens should be rejected by tenant B’s service.audnot validated — accepting tokens meant for other audiences. A token scoped tohttps://api-v1.example.commight work onhttps://api-v2.example.comif the audience check is missing.Clock skew tolerance too wide — accepting tokens expired hours or days ago. The spec says “NTP clock skew should be tolerated,” but most implementations don’t document their tolerance. Set it explicitly (30s is common).
Scope Enforcement: The Real Security Boundary
The scope is where OAuth actually enforces authorization. The token says “I can read user profiles.” The resource server checks: “Does this endpoint need user_profile:read? Yes. Is that scope in the token? Yes. Allow.”
But scope enforcement is only as good as the resource server’s implementation:
# Good: explicit scope check
def get_user_profile(token):
if "user_profile:read" not in token.scope:
return 403, "Insufficient scope"
return 200, fetch_profile(token.sub)
# Bad: trusts the token's subject without scope check
def get_user_profile(token):
return 200, fetch_profile(token.sub) # No scope check — might return write data
Scope naming convention matters. user_profile:read is clearer than read:profile. Use resource:action format consistently. Inconsistent naming leads to scope creep — developers grant broad scopes (“all”) because they can’t remember the exact granular scope name.
Token Lifespan and Refresh
Access tokens should be short-lived (minutes, not hours). Long-lived tokens are a liability: if stolen, they’re usable for longer. Refresh tokens are long-lived but typically can’t be used directly at the resource server — they’re exchanged for new access tokens at the auth server.
Client → Auth Server: GET /user/me (access_token=at-abc123)
Auth Server: 401 Unauthorized (token expired)
Client → Auth Server: POST /token (grant_type=refresh_token, refresh_token=rt-xyz789)
Auth Server: { access_token: at-def456, refresh_token: rt-new000, expires_in: 300 }
Client → Auth Server: GET /user/me (access_token=at-def456)
Auth Server: 200 OK
Refresh token rotation is a best practice: every refresh exchanges the old refresh token for a new one. If a refresh token is stolen, the attacker gets one new access token and the old one becomes invalid. Without rotation, the stolen refresh token stays valid until it expires.
Refresh token binding ties the refresh token to the client (via azp or client_secret) and optionally to the original token’s scope. This prevents a client from refreshing with a broader scope than it originally received.
Token Storage: Where Tokens Live
The storage location determines the attack surface:
| Location | Pros | Cons |
|---|---|---|
| Memory only | Not exposed to JS, XSS-proof | Lost on page refresh (unless persisted) |
localStorage | Persistent, easy to access | Exposed to XSS |
HttpOnly cookie | Protected from XSS, automatic send | Vulnerable to CSRF (unless SameSite) |
sessionStorage | Tab-scoped, persists refresh | Still XSS-exposed |
| Authorization header | Standard, no cookie quirks | Visible in referer (if not stripped) |
For SPAs (single-page apps), the common pattern is:
- Access token in memory (or
sessionStoragefor tab persistence) - Refresh token in an
HttpOnly; Secure; SameSite=Strictcookie - Authorization header for API calls
For server-side apps, the access token lives in server memory or a session store. The cookie is the session identifier, not the OAuth token.
The most common mistake: storing the access token in localStorage without considering what JS runs on the page. A third-party analytics library, a misconfigured ad tag, or a XSS vulnerability all read localStorage and steal the token.
Token Revocation: The Spec’s Missing Piece
RFC 6749 includes a revocation endpoint, but many implementations don’t support it. Without revocation, a token is valid until it expires. This is fine for short-lived access tokens but problematic for refresh tokens.
Revocation strategies:
Opaque tokens + database lookup — the simplest. Revoke by deleting the database record. Works because the token itself doesn’t contain state; the resource server always checks the database.
JWT with short expiry — revoke by invalidating a set of token IDs (jti claim) or by issuer-wide key rotation. Key rotation is aggressive but effective: revoke everything by changing the signing key.
JWT with audience-based revocation — each service maintains its own revocation list. Complex but precise — revoke access for one service without affecting others.
Refresh token revocation is critical. When a user logs out, the refresh token should be revoked. When it changes (rotation), the old one should be revoked. When the user changes password, all refresh tokens should be revoked.
Practical Security Checklist for OAuth Implementations
These are the things I check when auditing an OAuth setup:
- Algorithm whitelist — only accept expected signing algorithms (RS256, ES256). Reject
none,HS256with public keys. - Issuer validation — check
issagainst known auth servers. - Audience validation — check
audmatches this service. - Expiration check — validate
exp, tolerate clock skew explicitly. - Scope enforcement — every protected endpoint checks its required scope.
- Short access token lifetime — 5-15 minutes max.
- Refresh token rotation — old refresh token invalidated on use.
- Secure storage — access token not in
localStorageunless XSS-resistant. - Revocation supported — tokens can be invalidated before expiry.
azpvalidated — in multi-client environments, verify the token belongs to the presenting client.
Conclusion
OAuth access tokens are simple by design. They’re references to permissions, nothing more. The security comes from how you validate, store, and enforce them. Most vulnerabilities aren’t in the protocol — they’re in the implementation details that the spec leaves as convention.
The practitioners who understand OAuth deeply don’t memorize every grant type. They understand what a token represents, where it travels, what claims it carries, and what happens when it’s stolen. Everything else is configuration.
Further Reading
- RFC 6749 — The OAuth 2.0 Authorization Framework
- RFC 6750 — The OAuth 2.0 Authorization Framework: Bearer Token Usage
- RFC 7662 — OAuth 2.0 Token Introspection
- OpenID Connect Core 1.0
- A Practical Guide to OAuth 2.0 Security (Apple’s App Platform Security — native apps)
- OAuth 2.0 Security Best Current Practice (RFC 6819)