OAuth vs JWT

This comparison gets searched for constantly, and it contains a hidden mistake: OAuth 2.0 and JWT are not competing options. One is an authorisation framework, the other is a token format, they sit at different layers, and they are most often used together. This guide untangles them, explains each properly, and replaces “which should I use” with the two questions you should actually be asking. Test any of it against a real endpoint in the API tester.

The short answer

OAuth 2.0 (RFC 6749) is an authorisation framework. It defines a set of flows by which a client application obtains a token that lets it access a resource owner's data — crucially, without the client ever handling that person's password. It describes endpoints, redirects, parameters and grant types. It deliberately says nothing about what the token contains.

JWT (RFC 7519) is a token format. It defines a compact, URL-safe way to represent a set of claims as a signed, base64url-encoded JSON structure. It describes bytes. It says nothing about how a client got hold of one.

So the two combine rather than compete. An OAuth authorisation server may issue JWTs as access tokens, or it may issue opaque random strings — both are conformant. Equally, a system with a single monolithic login and no authorisation server anywhere can happily issue JWTs to its own users. Asking “OAuth or JWT?” is like asking “courier service or envelope?”.

OAuth 2.0JWT
CategoryAuthorisation framework — a protocolToken format — a data structure
SpecificationRFC 6749, plus RFC 6750 for bearer usageRFC 7519, on top of JWS (RFC 7515)
What it specifiesRoles, endpoints, redirects, grant types, parameters, error responsesHeader, payload claims, signing, base64url encoding
What it does not specifyThe token's format or contentsHow the token is obtained, stored or transmitted
Answers the question“How does the client get a token?”“What does the token look like?”
Typical useDelegated access, third-party integrations, SSOAccess tokens, ID tokens, stateless sessions, service-to-service claims
Can exist without the otherYes — OAuth with opaque tokensYes — JWTs in a system with no OAuth

A third piece completes the picture: whatever token you end up with usually travels as Authorization: Bearer <token>. That transport layer is covered in the bearer token guide, and the broader landscape of schemes in the API authentication overview.

OAuth 2.0 properly

OAuth exists to solve one problem: letting an application act on your behalf against a service, with limited permissions, without you giving that application your password. Before it, the “solution” was to hand a third-party site your bank or email credentials and hope. OAuth replaces that with a delegated grant.

The four roles

RoleWho it is
Resource ownerThe user who owns the data and can grant access to it
ClientThe application requesting access — a web app, SPA, mobile app or service
Authorisation serverAuthenticates the resource owner, obtains consent, issues tokens
Resource serverThe API holding the data; validates tokens and serves requests

The authorisation server and resource server are often operated by the same company but are logically separate, and separating them is what makes tokens portable. Clients are further split into confidential clients, which can keep a secret (a backend service), and public clients, which cannot (SPAs and mobile apps, whose code the user can read).

The grant types that matter today

GrantUse it forStatus
Authorization Code + PKCE (RFC 7636)Any flow involving a user: server-rendered apps, SPAs, mobileThe default; PKCE recommended for all client types
Client CredentialsMachine-to-machine, no user presentCurrent
Refresh TokenObtaining a fresh access token without re-promptingCurrent; rotate for public clients
Device Authorization Grant (RFC 8628)TVs, consoles, CLIs — devices with no browser or keyboardCurrent
ImplicitFormerly SPAsDeprecated by the OAuth 2.0 Security BCP
Resource Owner Password CredentialsFormerly first-party appsDeprecated by the OAuth 2.0 Security BCP

Take those last two seriously. Implicit returned the access token directly in the redirect URL fragment, exposing it to browser history, the Referer header and any script on the page; Authorization Code with PKCE now covers that case properly. Password grant requires the client to collect the user's actual username and password, which destroys the entire premise of OAuth and is incompatible with multi-factor authentication and federated login. Both still appear in older tutorials. Do not treat them as live options.

The authorization code flow, step by step

This is the flow worth knowing in detail, because everything else is a variation on it.

  1. The client generates a random code_verifier and derives code_challenge = BASE64URL(SHA256(code_verifier)), and separately generates a random state value it stores in the user's session.
  2. It redirects the browser to the authorisation endpoint with response_type=code, client_id, redirect_uri, scope, state, code_challenge and code_challenge_method=S256.
  3. The authorisation server authenticates the user — password, passkey, MFA, whatever it uses — and shows a consent screen listing the requested scopes.
  4. On approval it redirects back to redirect_uri with code and the same state. The code is short-lived and single-use.
  5. The client checks that state matches what it stored, then POSTs to the token endpoint with grant_type=authorization_code, code, redirect_uri, client_id and the original code_verifier — as application/x-www-form-urlencoded, not JSON, which is a classic first-attempt failure.
  6. The authorisation server hashes the code_verifier, compares it with the stored code_challenge, and on success returns access_token, token_type (Bearer), expires_in, usually refresh_token and, if openid was requested, id_token.

The two security parameters defend against different attacks and neither substitutes for the other. state defends the redirect against cross-site request forgery: without it an attacker can feed a victim's browser an authorisation response for the attacker's own account, silently linking the victim's session to attacker-controlled data. PKCE defends against authorization code interception: on a mobile device a malicious app can register the same custom URL scheme and capture the redirect, and on any platform the code may leak through logs or the Referer. A stolen code is useless without the code_verifier, which never leaves the client. PKCE was originally designed for mobile; it is now recommended for confidential clients too.

OAuth 2.1 is a draft that consolidates RFC 6749, the security BCP and several extensions into one document — PKCE required everywhere, implicit and password grants removed, exact redirect-URI matching, bearer tokens out of query strings. It is work in progress at the IETF and not a published RFC, so cite it as direction of travel rather than as a standard to conform to. Following it today is nonetheless good advice, because it mostly codifies what the BCP already recommends.

OpenID Connect: the missing identity layer

Here is the gap that causes more real bugs than the OAuth-vs-JWT confusion itself. An OAuth access token tells you nothing about who the user is. It is a capability — “the holder may read this mailbox” — not an assertion of identity. It carries no guarantee about when, how, or even whether a human authenticated.

OpenID Connect (OIDC) is the identity layer built on top of OAuth 2.0 that fills the gap. It adds four things: the openid scope that requests identity; the id_token, which is always a JWT and which asserts that a specific user authenticated at a specific time at a specific issuer; a UserInfo endpoint returning profile claims for a given access token; and discovery, a well-known JSON document advertising endpoints, supported scopes and the JWKS URL for signing keys. The classic one-liner is accurate: OAuth is authorisation, OIDC is authentication.

This is also where OAuth and JWT stop being independent: an id_token is required to be a JWT. Everywhere else the format is a free choice; here it is not.

Why “log in with an access token” is an anti-pattern

The broken pattern goes: the client obtains an access token from a provider, calls the provider's profile API, gets back an email address, and logs the user in as whoever owns that address. The flaw is that the client never verified who the token was issued to. An access token is a bearer credential — see the bearer token guide — so any application the same user authorised can take the token it legitimately received and present it to your API to impersonate that user. This is the confused-deputy problem that gave rise to real-world “log in as anyone” vulnerabilities in early social login integrations.

An id_token closes it because it is addressed. Its aud claim names your client_id, its iss names the issuer, its nonce ties it to your specific authentication request, and its signature proves the issuer produced it. Validate all of those and a token minted for another application simply fails the check. If you need identity, use OIDC and validate the id_token — do not infer identity from an access token.

JWT, briefly

A JWT is three base64url segments separated by dots: header.payload.signature. The header names the algorithm and usually a key id; the payload holds the claims; the signature covers the first two segments joined by a dot. Registered claims from RFC 7519 include iss (issuer), sub (subject), aud (audience), exp (expiry), nbf (not before), iat (issued at) and jti (a unique id, which is what a denylist keys on).

Two facts do most of the work in practice. First, the payload is encoded, not encrypted — base64url is a reversible transformation with no key, so anyone who holds the token can read every claim in it. Never put a secret, a password, an internal identifier you would not publish, or personal data you would not email in clear text into a JWT payload. Second, signing choice matters: HS256 uses a shared secret, so every party that can verify can also forge; RS256 and ES256 are asymmetric, so verifiers hold only a public key. The moment a separate party verifies your tokens, asymmetric is the right answer.

JWT is one member of the wider JOSE family. JWS (RFC 7515) is the signed structure that ordinary JWTs use; JWE (RFC 7516) is the encrypted one, and it genuinely hides the payload at the cost of complexity and of the resource server needing a decryption key. Alongside them sit JWK for key representation and JWA for algorithm identifiers.

This page deliberately stops there. The JWT decoder covers claim-by-claim detail, the alg confusion and none-algorithm attacks, HS256 versus RS256 versus ES256, storage, and how to read a token you have been handed — and it will decode one for you while you read.

The real trade-off: self-contained vs reference tokens

Strip away the terminology and the genuine architectural decision is this: should a resource server be able to validate a token on its own, or should it ask the issuer?

DimensionSelf-contained (JWT)Reference (opaque)
ValidationLocal signature check, microseconds, no networkLookup or introspection call per request, or a cache with its own staleness
ScalingExcellent — no shared session store on the hot pathBounded by the store; needs caching and capacity planning
RevocationEffective only at exp, unless you add a denylistImmediate — delete the record
Claim freshnessStale from the instant of issue; a role removed mid-token is still in the tokenAlways current
SizeGrows with claims; can bloat headers past proxy limitsSmall and constant
ConfidentialityClaims readable by anyone holding the tokenNothing to read
Failure modeIssuer outage does not stop validationStore outage stops everything

JWT revocation is the hard problem, and it should not be glossed over. The property that makes a JWT fast — offline validation — is exactly the property that makes revocation awkward. If a resource server validates locally, then a token with a valid signature and an exp in the future will be accepted, whatever has happened at the issuer since. The user logged out; the account was suspended; an admin removed a role. None of that reaches the resource server. Three mitigations exist, and each costs something:

  • A denylist keyed by jti. Works, and reintroduces the shared state you chose JWTs to avoid — though only for revoked tokens until they expire, which is a much smaller store than a session table.
  • Very short lifetimes plus refresh. A five-minute access token bounds the exposure window, and revocation is enforced at the refresh, where you already have state. This is the mainstream answer, and it is a bound rather than a fix.
  • Token introspection (RFC 7662). Ask the authorisation server whether the token is still active — at which point you have the per-request lookup you were avoiding, and the token being a JWT has stopped buying you much.

Notice that none of these is a clean win, which is the honest conclusion: pick based on whether immediate revocation or independent validation matters more for the thing you are protecting. Many mature systems do both — JWTs for high-volume read APIs, an introspected or opaque token for anything that moves money.

Decision guide by scenario

What you are buildingWhat to reach for
“Let users sign in with Google / Microsoft / Apple” OIDC on top of OAuth 2.0. Authorization Code + PKCE, openid scope, validate the id_token's iss, aud, exp and nonce. Do not roll this yourself.
Your own SPA talking to your own API Authorization Code + PKCE against your own authorisation server. Token format is genuinely your choice — JWT if you have several services validating, opaque if one gateway does it.
Service-to-service inside one cluster Client Credentials, or mTLS if the mesh already provides it. Either token format works; a short-lived JWT with a narrow aud avoids a shared lookup on every hop.
Third-party developers accessing your users' data OAuth 2.0, unambiguously. This is the exact problem it was invented for: scoped, consented, revocable delegated access without your users handing over passwords.
A single monolith with its own login OAuth is probably ceremony you do not need. A server-side session with an httpOnly, Secure, SameSite cookie is simpler, revocable by default and hard to get wrong. A signed token is fine too.
A CLI or a smart TV app Device Authorization Grant (RFC 8628) — the device shows a code, the user approves it on a phone, the device polls the token endpoint.
A cron job hitting your own API Client Credentials, or a scoped API key in a secrets manager. No user, so no user-facing flow is needed.

Two pieces of pragmatism worth stating plainly. First, OAuth is frequently overkill. If there is no third party, no delegated access and no cross-domain identity, adding an authorisation server buys you complexity, redirect debugging and a new outage surface in exchange for benefits you are not using. Second, when OAuth is right, use a maintained library or a hosted provider. The flows are simple to describe and easy to implement subtly wrong — redirect-URI matching, state handling, token storage — and each mistake is a full account takeover. Whichever route you take, the end state is the same: a token you can paste into the API tester to confirm the resource server accepts it before you wire it into an application.

Common misconceptions

“OAuth is authentication”

It is not. OAuth answers “may this client do this thing?”, not “who is this person?”. The identity layer is OpenID Connect, and the artefact that carries identity is the id_token. Treating an access token as proof of login is the confused-deputy anti-pattern described above.

“JWTs are secure by default”

A JWT is secure only if you validate it properly: check that alg matches what you expect rather than trusting the header, that iss is your issuer, that aud names your service, and that exp and nbf are satisfied with a small clock leeway. Skip the aud check and any token from the same issuer works against your API. Skip the alg check and you are exposed to algorithm confusion — the JWT decoder walks through exactly how those attacks work.

“JWTs are encrypted”

No. A signed JWT is a JWS; the payload is base64url-encoded and readable by anyone who has the token. Signing proves integrity and origin, not confidentiality. JWE (RFC 7516) is the encrypted variant, and it is a different structure with a different shape.

“OAuth means JWT”

RFC 6749 never mentions a token format. GitHub, among many others, issues opaque access tokens, and RFC 7662 introspection exists precisely because opaque OAuth tokens are common. If you assume every OAuth token can be decoded, your integration will break the first time you meet one that cannot.

“Stateless means no database”

Stateless means the token carries its own claims so validation needs no lookup. You still have a database — for users, permissions, refresh tokens, revocations and audit. What JWTs remove is one lookup on the request hot path, which is a real performance win and not an architectural exemption.

“Longer expiry is more convenient”

It trades a user-experience problem you can solve for a security problem you cannot. Silent refresh solves the convenience; nothing solves a long-lived stolen bearer token except waiting for it to expire.

Seeing it work

Both halves are easier to believe once you have watched them. For the JWT half, take any token you have and drop it into the JWT decoder — you will see the header, every claim and the expiry, without a key, which makes the “encoded, not encrypted” point better than any paragraph can. For the OAuth half, take an access token you already hold and call a protected endpoint from the API tester with the Auth tab set to Bearer; then compare what happens with no token (401, with a WWW-Authenticate challenge) and with a valid token missing a scope (403). Token endpoints themselves are worth exercising in the REST API tester: they take application/x-www-form-urlencoded bodies, and seeing the raw response teaches more than reading about expires_in.

From here: the bearer token guide covers how the token actually travels and how to stop leaking it, the API authentication guide surveys the other schemes, and the HTTP headers reference explains the Authorization and WWW-Authenticate fields in their wider context.

Frequently asked questions

Is OAuth better than JWT?

Neither, because they are not alternatives. OAuth 2.0 is a framework for how a client obtains a token; JWT is a format for what a token looks like. An OAuth server can issue JWTs or opaque tokens, and JWTs appear in plenty of systems with no OAuth at all.

Can you use OAuth without JWT?

Yes. RFC 6749 never specifies a token format, so an authorisation server may issue opaque strings that resource servers validate via the RFC 7662 introspection endpoint. GitHub is a well-known example.

Is OAuth 2.0 authentication or authorisation?

Authorisation. An access token grants delegated access; it asserts nothing reliable about identity. OpenID Connect is the identity layer on top, adding the id_token, the openid scope and a UserInfo endpoint.

Are JWTs encrypted?

A signed JWT is not. The payload is base64url-encoded and readable by anyone holding the token. Encryption requires JWE (RFC 7516), a separate structure. Never put secrets in a signed JWT payload.

How do you revoke a JWT?

There is no clean way. Offline validation accepts any correctly signed, unexpired token, so you need a denylist keyed by jti, very short lifetimes with refresh, or introspection on every request — and the last two reintroduce the state you were avoiding.

Which OAuth grant type should I use?

Authorization Code with PKCE whenever a user is involved, Client Credentials for machine-to-machine, and the Device Authorization Grant for TVs and CLIs. The Implicit and Password grants are deprecated by the OAuth 2.0 Security Best Current Practice.