Complete practical guide ยท API tokens & JWT
A token is
a claim.
Your API checks it.
API keys, bearer tokens, session cookies, OAuth access and refresh tokens, JWTs โ they get used interchangeably, and most auth bugs start right there. This page separates them, takes a JWT apart segment by segment, and gives you the validation checklist, storage trade-offs and code you actually need. ๐
Which algorithm signed it, and which key (kid) to verify with.
The claims: who, for which API, until when. Readable by anyone holding the token.
Proves the first two weren't changed. Hides nothing โ it's integrity, not secrecy.
Who this is for: a developer building or calling a protected API who wants to know which credential to use, what a JWT really guarantees, and which checks the server must never skip. Want to look inside a token while you read? Open the JWT Decoder in another tab.
Authentication vs authorization. Authentication answers "who are you?"; authorization answers "what may you do?". A perfectly valid token identifies a caller โ it does not, by itself, grant access to every endpoint. That's why APIs return 401 (missing, expired or invalid credentials) and 403 (known caller, not allowed) as two different answers.
02 ยท Choose the right credential
๐๏ธ API keys, tokens, cookies โ what's the difference?
Each credential has a different owner, audience and lifetime. Most "our auth is weird" problems come from using one where another belongs โ an ID token sent to an API, a refresh token sent everywhere, an API key shipped in a mobile app.
| Credential | Represents | Sent to | Typical lifetime | The rule people break |
|---|---|---|---|---|
| ๐ง API key | An application or project, not a user | The target API | Months, until rotated | Never ship it in browser or mobile code. Scope it, restrict it by IP/origin/quota, rotate it. |
| ๐ซ Bearer token | Whatever it was issued for โ the transport rule, not a format | Any API that accepts it | Varies | Whoever holds it can use it. Protect it in transit, logs and storage like a password. |
| ๐ช Session cookie | A server-side session (the cookie is just an ID) | Your own web backend | Session, sliding | Must be HttpOnly, Secure, SameSite โ and needs CSRF protection. |
| โ OAuth access token | Delegated access: this client may do these scopes for this user | The resource API (aud) | Minutes to an hour | May be a JWT or opaque. Clients should treat it as an opaque string. |
| ๐ Refresh token | Permission to get a new access token | Only the authorization server | Days to weeks | Never send it to a normal API endpoint. Rotate it on every use. |
| ๐ชช ID token (OIDC) | "This user just signed in" โ for the client app | The client application | Minutes | It is not an API access token. Don't forward it to your backend as one. |
๐๏ธ Opaque tokens
A random string with no readable content. The API asks the authorization server what it means (token introspection, RFC 7662) or looks it up in a store.
- Instantly revocable โ delete the row
- Leaks nothing if logged
- A lookup on every request (cacheable)
๐งพ Self-contained JWTs
The claims travel inside the token and are signed. The API verifies the signature locally with a key it already has.
- No network call per request โ scales well
- Works across services and domains
- Hard to revoke before
exp
Rule of thumb: a browser app talking only to its own backend usually wants a plain session cookie. JWT access tokens earn their complexity when several APIs, services or third parties need to trust one issuer.
03 ยท Take one apart
๐ฌ JWT structure: header.payload.signature
A signed JWT (technically a JWS in compact serialization) is three Base64URL-encoded segments joined by dots. Base64URL is Base64 with - and _ in place of + and /, and no = padding โ so the token is safe in headers and URLs. If the encoding itself is new to you, read What is Base64? first.
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9 .eyJpc3MiOiJodHRwczovL2F1dGguZXhhbXBsZS5jb20iLCJzdWIiOiJ1c2VyLTEwNDIiLCJhdWQiOiJvcmRlcnMtYXBpIiwiZXhwIjoxNzkwMDAwMDAwLCJpYXQiOjE3ODk5OTkxMDAsInNjb3BlIjoib3JkZXJzLnJlYWQifQ .wEBOIaIbyB-hsgq_K-m2us0umpzmgFWeWlTla9aI2OE
// header { "alg": "HS256", "typ": "JWT" } // payload { "iss": "https://auth.example.com", "sub": "user-1042", "aud": "orders-api", "exp": 1790000000, // 2026-09-21 14:13:20 UTC "iat": 1789999100, // 15 minutes earlier "scope": "orders.read" } // signature HMAC-SHA256( base64url(header) + "." + base64url(payload), secret )
Paste that token into the JWT Decoder and you'll get exactly the JSON above โ without any key. That is the single most important fact about JWTs:
Encoded is not encrypted. Anyone who holds a JWT can read its header and payload. Never put passwords, secrets, card data or personal data you wouldn't print in a log into the payload. If the contents must be confidential, you need an encrypted JWT (JWE) โ or better, an opaque token.
Decoding is not verifying. A decoder shows what a token claims. Anyone can hand-craft a token with "sub": "admin"; only signature verification with a trusted key tells you whether the issuer actually said it. Want to see Base64URL do its thing on your own JSON? Try the Base64 Encoder/Decoder.
04 ยท Read the payload
๐ท๏ธ Standard JWT claims explained
RFC 7519 registers seven short claim names. None are mandatory in the spec, but a sane access token uses almost all of them โ and your API should check the ones marked below.
| Claim | Name | Meaning | API must check? |
|---|---|---|---|
| iss | Issuer | Who created the token โ usually your identity provider's URL. | Yes exact match against an allow-list |
| sub | Subject | The user, service or entity the token is about. Stable ID, not an email. | Use it for ownership checks |
| aud | Audience | Which API the token is for. A string or an array. | Yes must contain your API |
| exp | Expiration | Unix time (seconds) after which the token must be rejected. | Yes with small clock skew |
| nbf | Not before | Unix time before which the token is not yet valid. | Yes if present |
| iat | Issued at | When the token was minted. Handy for "max age" rules. | Optional |
| jti | JWT ID | Unique token ID โ for replay detection, audit logs and deny-lists. | Optional needed for revocation |
Beyond those, you'll meet common non-registered claims such as scope (space-separated permissions, e.g. "orders.read orders.write"), roles, azp/client_id (which app requested it), tid (tenant) and sid (session). Their meaning is defined by your identity provider, not the JWT spec.
Timestamps are seconds, not milliseconds. exp: 1790000000 is a 10-digit Unix time. A 13-digit value means someone used Date.now() without dividing by 1000 โ and the token is valid for the next fifty thousand years.
05 ยท Who holds which key
โ๏ธ Signing: HS256 vs RS256 vs ES256
The alg header names the algorithm, but the real decision is who needs to verify tokens, and should they also be able to create them?
๐ HS256 โ shared secret
HMAC + SHA-256 ยท symmetric
One secret signs and verifies. Simple and fast, but every service that can verify a token can also mint one.
- Fine when one app issues and checks its own tokens
- Secret must be random and โฅ 256 bits โ not a word
- Rotating means redeploying everywhere at once
๐๏ธ RS256 / ES256 โ key pair
RSA or ECDSA ยท asymmetric
The issuer signs with a private key; APIs verify with the public key, usually fetched from a JWKS endpoint and picked by the header's kid.
- The default for identity providers and multi-service systems
- ES256 (and EdDSA) give smaller keys and signatures than RS256
- Key rotation = publish a new
kid, retire the old one later
Never let the token choose the algorithm. Two classic attacks exploit libraries that trust the header: "alg": "none" (an unsigned token accepted as valid), and algorithm confusion โ flipping RS256 to HS256 so the server uses its public key as an HMAC secret, which the attacker also has. The fix is the same for both: the server pins an explicit allow-list of algorithms per key.
๐ก Need a strong random HS256 secret or a test API key? The Token Generator and Password Generator produce them in your browser. In production, keep secrets in a vault or secret manager, never in the repo.
06 ยท The server's job
๐ก๏ธ JWT validation checklist
Every protected request, in this order. A mature library does most of it for you โ if you configure it. Defaults vary, and "it decoded fine" is not a check.
-
๐จ Extract it strictly
Read only
Authorization: Bearer <token>(or your one agreed cookie). Reject tokens in query strings โ URLs end up in logs, browser history andRefererheaders. -
๐ค Check the algorithm against an allow-list
Accept only what your issuer uses, e.g.
RS256. Rejectnonealways, and never switch key type based on the header. -
๐ Verify the signature with a trusted key
The key comes from your configuration or your issuer's JWKS endpoint โ never from the token itself (ignore embedded
jku/x5u/jwkheaders unless they point at a URL you already trust). -
โฐ Check
expandnbfwith small clock skewAllow 30โ60 seconds for clock drift between servers. Note that ASP.NET Core's default
ClockSkewis five minutes โ a 5-minute token then effectively lives ten. -
๐ฏ Match
issandaudexactlyThe issuer must be one you trust; the audience must include this API. Skipping
audmeans a token minted for some other API on the same identity provider works on yours. -
๐ฆ Then authorize
A valid token only proves who's calling. Now check scopes, roles, tenant and โ the one most often forgotten โ whether
subactually owns the resource in the URL. Return 403 when it doesn't.
Fail closed and quietly. Any failure โ 401 with a WWW-Authenticate: Bearer header and a generic message. Log the reason server-side, but never log the full token โ log its jti or a hash instead.
07 ยท Paste-able examples
๐ป Sending and verifying tokens in code
๐ฎ Authorization header formats
The Authorization header is <scheme> <credentials>. The scheme tells the server how to read the rest. Build any of these without typos using the Authorization Header Generator.
| Scheme | Looks like | Used for |
|---|---|---|
| Bearer | Authorization: Bearer eyJhbGciOiโฆ | OAuth access tokens and JWTs. By far the most common. |
| Basic | Authorization: Basic dXNlcjpwYXNz | Base64 of user:password โ readable, so HTTPS only. Common for OAuth client credentials. |
| API key | X-API-Key: sk_live_โฆ | Vendor-specific; some use Authorization: ApiKey โฆ instead. Check the API's docs. |
| DPoP | Authorization: DPoP eyJโฆ + DPoP: eyJโฆ | Sender-constrained tokens (RFC 9449): a stolen token is useless without the client's private key. |
| Signature | Authorization: AWS4-HMAC-SHA256 Credential=โฆ | Request signing (e.g. AWS SigV4). The secret never travels; each request is signed. |
โจ๏ธ curl
# call a protected API with a bearer token curl -H "Authorization: Bearer $ACCESS_TOKEN" \ https://api.example.com/api/orders/1042 # service-to-service: get a token with the client credentials grant curl -u "$CLIENT_ID:$CLIENT_SECRET" \ -d grant_type=client_credentials -d scope=orders.read \ https://auth.example.com/oauth2/token # API key in a custom header curl -H "X-API-Key: $API_KEY" https://api.example.com/v1/status # see the 401 + WWW-Authenticate response when it fails curl -i https://api.example.com/api/orders/1042
๐ฃ ASP.NET Core 8 โ JwtBearer
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;
builder.Services
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
// Authority โ discovers the issuer's signing keys (JWKS) automatically
options.Authority = "https://auth.example.com/";
options.Audience = "orders-api";
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidIssuer = "https://auth.example.com/",
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ValidAlgorithms = new[] { SecurityAlgorithms.RsaSha256 },
ClockSkew = TimeSpan.FromSeconds(30) // default is 5 min
};
});
builder.Services.AddAuthorization(o =>
o.AddPolicy("orders.read", p => p.RequireAssertion(ctx =>
(ctx.User.FindFirst("scope")?.Value ?? "")
.Split(' ').Contains("orders.read"))));
var app = builder.Build();
app.UseAuthentication(); // order matters: authenticate firstโฆ
app.UseAuthorization(); // โฆthen authorize
app.MapGet("/api/orders/{id}", (int id) => Results.Ok(new { id }))
.RequireAuthorization("orders.read");
๐ก Issuing your own HS256 tokens instead of using an identity provider? Drop Authority, set IssuerSigningKey = new SymmetricSecurityKey(keyBytes) from configuration, and change ValidAlgorithms to SecurityAlgorithms.HmacSha256.
๐ข Node.js โ jsonwebtoken
import jwt from "jsonwebtoken";
export function requireAuth(req, res, next) {
const [scheme, token] = (req.headers.authorization ?? "").split(" ");
if (scheme !== "Bearer" || !token) {
return res.status(401).set("WWW-Authenticate", "Bearer").end();
}
try {
req.user = jwt.verify(token, PUBLIC_KEY_PEM, {
algorithms: ["RS256"], // allow-list โ never omit
issuer: "https://auth.example.com/",
audience: "orders-api",
clockTolerance: 30, // seconds
});
return next();
} catch {
return res.status(401).set("WWW-Authenticate", "Bearer").end();
}
}
๐ก jwt.decode() in the same library does no verification โ it's for debugging only. For JWKS key fetching and rotation, pair it with jwks-rsa, or use the jose library's createRemoteJWKSet.
08 ยท Where the browser keeps it
๐ช Storing tokens in browsers: cookie vs localStorage
There's no perfectly safe place in a browser. The honest question is which attack you'd rather defend against: XSS (malicious script in your page) or CSRF (another site making your browser send requests).
SameSite=Lax/Strict plus an anti-forgery token for state-changing requests.fetch call, to any API.Secure, HttpOnly, SameSite, __Host- prefix โ the browser enforces the rules for you.๐ฅ Backend-for-frontend
Recommended for SPAs
A thin server holds the OAuth tokens; the browser only gets an HttpOnly session cookie. Tokens never touch JavaScript.
๐ฅ In-memory access token
Acceptable
Short-lived access token in a JS variable, refresh token in an HttpOnly cookie scoped to the refresh endpoint. Lost on reload, then silently refreshed.
๐ฑ Native apps
Different rules
Use the platform's Keychain (iOS) or Keystore (Android) โ not plain preferences files โ and the OAuth authorization code flow with PKCE.
HttpOnly doesn't make XSS harmless. Injected script can still send requests as the user while the page is open โ it just can't take the token away with it. Content Security Policy and output encoding remain your real XSS defence.
09 ยท Refresh, logout, revoke
๐ Refresh token rotation and the limits of stateless JWTs
Apps that keep you signed in for weeks aren't using one long-lived token. They pair a short-lived access token (minutes) with a protected refresh token that's only ever sent to the authorization server.
-
๐ Sign in once
Password, passkey or external identity provider. The client receives an access token and a refresh token.
-
๐ก Call APIs with the access token
Until it expires โ the API answers 401, or the client refreshes a little before
exp. -
โป๏ธ Refresh โ and rotate
Each refresh returns a new refresh token and invalidates the old one. If an old refresh token is ever presented again, someone copied it: revoke the whole token family and force sign-in.
-
โณ Cap the session
Use a sliding window for activity and an absolute maximum (say 30 days) so a session can't silently live forever.
๐ช Why "logout" doesn't kill a JWT
A self-contained JWT is valid until exp because the API never asks anyone. Logout clears the client and revokes the refresh token โ but an access token already copied somewhere keeps working until it expires. Your options, from cheapest to most thorough:
| Strategy | How | Cost |
|---|---|---|
| โฑ๏ธ Short lifetimes | 5โ15 minute access tokens bound the damage window. | Cheap the baseline for everyone |
๐ Deny-list by jti | Store revoked token IDs until their exp; check on each request. | Moderate a fast cache lookup |
| ๐ข Session version | Put a version/security-stamp claim in the token; bump it on password change or "log out everywhere". | Moderate one lookup per user |
| ๐๏ธ Opaque + introspection | Every request asks the auth server whether the token is still active. | Highest but instant revocation |
โ Ship-it checklist
- โ HTTPS everywhere; tokens never in URLs or logs
- โ
Algorithm allow-list configured;
noneimpossible - โ
iss,aud,exp,nbfvalidated, clock skew โค 60 s - โ Scopes and resource ownership checked after authentication
- โ Access tokens short-lived; refresh tokens rotated with reuse detection
- โ Browser tokens in HttpOnly cookies or a BFF, with CSRF protection
- โ
Signing keys rotatable via
kid/ JWKS without downtime - โ A real answer to "how do we log a user out of every device?"
Debugging a token? The JWT Decoder shows the header, claims and human-readable timestamps. Use it with test tokens โ treat a production access or refresh token like a password and don't paste it into any tool you don't control.