API keys, OAuth 2.0 and JWTs solve different problems
An API key is a long-lived secret that identifies the calling application. OAuth 2.0 is a framework for issuing short-lived access tokens with limited scopes, often on behalf of a user. A JWT (JSON Web Token) is a token format, a signed set of claims, that OAuth servers often use for those access tokens.
That leaves two separate decisions. The first is how clients get credentials: an API key or an OAuth flow. The second is how your API checks them: a JWT it validates itself, or an opaque token it looks up.
API keys: simple and coarse
An API key is a random string sent with every request, usually in the Authorization header or a custom header such as X-API-Key. It identifies a project or account rather than a person, and it stays valid until someone revokes it.
Keys suit server-to-server calls to a provider and fit poorly anywhere code runs on a user's device. Anyone can read code shipped to a browser or a mobile app, so a key inside it is public.
Store keys in a secrets manager or environment variables, give each one the narrowest permissions the provider offers, and rotate them. Keep keys out of query strings, because URLs end up in server logs, proxies and browser history.
OAuth 2.0: pick the flow that fits the client
OAuth 2.0 (RFC 6749) gives a client an access token from an authorization server, so an app acting for a user never handles the user's password. The token carries scopes, such as orders:read, that limit what it can do, and it expires.
The OAuth security best current practice, RFC 9700, says clients should not use the implicit grant and must not use the password grant. OAuth covers authorization; for signing users in, OpenID Connect adds an ID token on top of it. Two flows cover most cases:
- Authorization code with PKCE, for apps acting for a user: server-rendered web apps, single-page apps and mobile apps. The user signs in at the authorization server, the app receives a one-time code, and it exchanges the code for tokens. PKCE (RFC 7636) binds the code to the app instance that started the flow, so an intercepted code is useless to anyone else.
- Client credentials, for service-to-service calls with no user involved. The service authenticates with its own client ID and secret, or a private key, and receives a token for its own access.
How to validate a JWT
A signed JWT has three base64url-encoded parts separated by dots: a header, a payload of claims and a signature. Anyone holding the token can decode the payload, so never put secrets in it.
Use a maintained JWT library and configure each check explicitly instead of trusting its defaults. RFC 8725 collects the current best practices. Accept a token only when all of these hold:
- The signature verifies with a key you trust, such as one your authorization server publishes, using the one algorithm you expect, such as RS256 or ES256.
- The algorithm comes from your configuration. A token whose alg header says none, or names an algorithm you didn't configure, is rejected; letting the header choose allows algorithm-confusion attacks.
- exp is in the future and nbf, if present, is not, allowing only a small clock skew.
- iss matches your authorization server exactly.
- aud includes your API, so a token issued for another service can't be replayed against yours.
- The scopes or other claims permit the operation being called.
{
"iss": "https://auth.example.com/",
"sub": "user_8412",
"aud": "https://api.example.com",
"scope": "orders:read",
"iat": 1767222000,
"exp": 1767225600
}Where to store tokens, and where not to
Where a token lives decides who can steal it. In a server-rendered web app, keep tokens on the server and give the browser a session cookie marked HttpOnly, Secure and SameSite.
HttpOnly stops scripts from reading the cookie, but injected script can still send requests that carry it. You still need protection against cross-site scripting (XSS), plus SameSite or CSRF tokens against cross-site requests.
Single-page apps are harder. Any script on the page can read tokens in localStorage or sessionStorage, so one XSS flaw exposes them. Holding tokens in memory narrows that risk but loses them on reload. A backend-for-frontend, a small server that holds tokens and calls the API for the browser, keeps them out of the browser entirely.
Mobile apps should use the platform's protected storage: the Keychain on iOS and storage backed by the Keystore on Android. Never put tokens in URLs, logs, analytics events or source control.
Revoking self-contained tokens
A JWT access token is self-contained: the API validates it with a public key and never asks the authorization server. That is fast, but the API can't see that a token was revoked, so a stolen JWT keeps working until it expires.
The usual answer is short access token lifetimes, often minutes, paired with refresh tokens that the authorization server can revoke. Cutting access sooner means adding a lookup back in:
- A deny list of revoked token IDs (the jti claim), checked on each request
- Opaque tokens checked with token introspection (RFC 7662), where revocation takes effect on the next check at the cost of a network call or a short cache
- Rotating the signing key, which invalidates every token signed with it at once