Skip to content
Browse tools

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. ๐Ÿ‘‡

๐Ÿ“‹
Segment 1
Header

Which algorithm signed it, and which key (kid) to verify with.

๐Ÿ“ฆ
Segment 2
Payload

The claims: who, for which API, until when. Readable by anyone holding the token.

โœ๏ธ
Segment 3
Signature

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.

CredentialRepresentsSent toTypical lifetimeThe rule people break
๐Ÿ”ง API keyAn application or project, not a userThe target APIMonths, until rotatedNever ship it in browser or mobile code. Scope it, restrict it by IP/origin/quota, rotate it.
๐ŸŽซ Bearer tokenWhatever it was issued for โ€” the transport rule, not a formatAny API that accepts itVariesWhoever holds it can use it. Protect it in transit, logs and storage like a password.
๐Ÿช Session cookieA server-side session (the cookie is just an ID)Your own web backendSession, slidingMust be HttpOnly, Secure, SameSite โ€” and needs CSRF protection.
โœ… OAuth access tokenDelegated access: this client may do these scopes for this userThe resource API (aud)Minutes to an hourMay be a JWT or opaque. Clients should treat it as an opaque string.
๐Ÿ”„ Refresh tokenPermission to get a new access tokenOnly the authorization serverDays to weeksNever send it to a normal API endpoint. Rotate it on every use.
๐Ÿชช ID token (OIDC)"This user just signed in" โ€” for the client appThe client applicationMinutesIt 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.

โ–ธ a real (demo) JWT โ€” split for readability
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
.eyJpc3MiOiJodHRwczovL2F1dGguZXhhbXBsZS5jb20iLCJzdWIiOiJ1c2VyLTEwNDIiLCJhdWQiOiJvcmRlcnMtYXBpIiwiZXhwIjoxNzkwMDAwMDAwLCJpYXQiOjE3ODk5OTkxMDAsInNjb3BlIjoib3JkZXJzLnJlYWQifQ
.wEBOIaIbyB-hsgq_K-m2us0umpzmgFWeWlTla9aI2OE
โ–ธ decoded
// 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.

ClaimNameMeaningAPI must check?
issIssuerWho created the token โ€” usually your identity provider's URL.Yes exact match against an allow-list
subSubjectThe user, service or entity the token is about. Stable ID, not an email.Use it for ownership checks
audAudienceWhich API the token is for. A string or an array.Yes must contain your API
expExpirationUnix time (seconds) after which the token must be rejected.Yes with small clock skew
nbfNot beforeUnix time before which the token is not yet valid.Yes if present
iatIssued atWhen the token was minted. Handy for "max age" rules.Optional
jtiJWT IDUnique 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.

  1. ๐Ÿ“จ 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 and Referer headers.

  2. ๐Ÿ”ค Check the algorithm against an allow-list

    Accept only what your issuer uses, e.g. RS256. Reject none always, and never switch key type based on the header.

  3. ๐Ÿ”‘ 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/jwk headers unless they point at a URL you already trust).

  4. โฐ Check exp and nbf with small clock skew

    Allow 30โ€“60 seconds for clock drift between servers. Note that ASP.NET Core's default ClockSkew is five minutes โ€” a 5-minute token then effectively lives ten.

  5. ๐ŸŽฏ Match iss and aud exactly

    The issuer must be one you trust; the audience must include this API. Skipping aud means a token minted for some other API on the same identity provider works on yours.

  6. ๐Ÿšฆ Then authorize

    A valid token only proves who's calling. Now check scopes, roles, tenant and โ€” the one most often forgotten โ€” whether sub actually 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.

SchemeLooks likeUsed for
BearerAuthorization: Bearer eyJhbGciOiโ€ฆOAuth access tokens and JWTs. By far the most common.
BasicAuthorization: Basic dXNlcjpwYXNzBase64 of user:password โ€” readable, so HTTPS only. Common for OAuth client credentials.
API keyX-API-Key: sk_live_โ€ฆVendor-specific; some use Authorization: ApiKey โ€ฆ instead. Check the API's docs.
DPoPAuthorization: DPoP eyJโ€ฆ + DPoP: eyJโ€ฆSender-constrained tokens (RFC 9449): a stolen token is useless without the client's private key.
SignatureAuthorization: AWS4-HMAC-SHA256 Credential=โ€ฆRequest signing (e.g. AWS SigV4). The secret never travels; each request is signed.

โŒจ๏ธ curl

โ–ธ terminal
# 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

๐Ÿ“„ Program.csdotnet add package Microsoft.AspNetCore.Authentication.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

๐Ÿ“„ auth.jsnpm install 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).

๐Ÿช HttpOnly cookie
๐Ÿ—„๏ธ localStorage / sessionStorage
๐Ÿช CookieJavaScript can't read it, so an XSS bug can't steal the token and replay it elsewhere.
๐Ÿ—„๏ธ StorageAny script on the page can read it โ€” including one injected via XSS or a compromised dependency.
๐Ÿช CookieSent automatically, so it needs CSRF defences: SameSite=Lax/Strict plus an anti-forgery token for state-changing requests.
๐Ÿ—„๏ธ StorageNever sent automatically, so classic CSRF doesn't apply.
๐Ÿช CookieTied to your domain โ€” awkward for calling third-party APIs directly.
๐Ÿ—„๏ธ StorageEasy to attach to any fetch call, to any API.
๐Ÿช CookieSecure, HttpOnly, SameSite, __Host- prefix โ€” the browser enforces the rules for you.
๐Ÿ—„๏ธ StoragePersists until explicitly cleared; survives tab closes (localStorage) with no expiry of its own.

๐Ÿฅ‡ 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.

  1. ๐Ÿ”“ Sign in once

    Password, passkey or external identity provider. The client receives an access token and a refresh token.

  2. ๐Ÿ“ก Call APIs with the access token

    Until it expires โ€” the API answers 401, or the client refreshes a little before exp.

  3. โ™ป๏ธ 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.

  4. โณ 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:

StrategyHowCost
โฑ๏ธ Short lifetimes5โ€“15 minute access tokens bound the damage window.Cheap the baseline for everyone
๐Ÿ“› Deny-list by jtiStore revoked token IDs until their exp; check on each request.Moderate a fast cache lookup
๐Ÿ”ข Session versionPut a version/security-stamp claim in the token; bump it on password change or "log out everywhere".Moderate one lookup per user
๐ŸŽŸ๏ธ Opaque + introspectionEvery 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; none impossible
  • โœ… iss, aud, exp, nbf validated, 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.

๐Ÿ“Œ Standards referenced: RFC 7519 (JWT), RFC 7515 (JWS), RFC 6750 (Bearer tokens), RFC 7662 (introspection), RFC 9449 (DPoP) and the OAuth 2.0 Security Best Current Practice. Library option names follow ASP.NET Core 8 and jsonwebtoken 9 โ€” check your version's docs before copying configuration.

API tokens & JWT
12 min read