Start a project

Article · APIs · Authentication · JWT · 12 min read

JWT, JWKS and API authentication explained

A JSON Web Token is a signed (and sometimes encrypted) set of statements about a subject, not a magic session cookie. The useful part is that a server can check those statements with a public key, without calling the issuer on every request. The dangerous part is how easy it is to decode a token, see a JSON payload, and forget that anyone can write a payload. Verification is the whole job.

On this page 10 sections
  1. A token is a statement, not a session magic bean
  2. JWS versus JWE, and when encryption is needed
  3. Claims: iss, aud, exp, and the ones you should add
  4. JWKS, kid and rotating keys
  5. Verification steps a server must not skip
  6. Access tokens, refresh tokens and storage on a client
  7. The usual ways this goes wrong
  8. How to inspect a token without trusting it
  9. Key takeaways
  10. Frequently asked questions

I keep a JWT Decoder, a JWT Encoder, a JWT & JWKS Signature Verifier and a JWE Decrypter on this site because I needed a place to inspect tokens without pasting them into a stranger’s webpage. What follows is the verification-first picture of how a small API should treat them.

A token is a statement, not a session magic bean

A compact JWT is three Base64URL segments, separated by dots: a header, a payload, and a signature (for the signed form). The payload is a JSON object of claims. The signature (or the encryption layer) is the only reason a receiver should believe those claims.

That leads to a rule you should treat as non-negotiable:

Never use the payload of a token you have not verified. Do not read sub, admin, exp or anything else from a decoded-but-unverified string, and do not write your own parser that “just splits on the dots” and JSON.parses the middle. A token with the algorithm none, or with a signature you did not check, is a user-controlled JSON blob.

A session cookie, by contrast, is usually an opaque pointer to state the server holds. A JWT is a statement the server has to check, and then may trust until it expires. If you need to revoke access immediately, a signed token on its own is a poor fit unless you also keep a block-list, a short expiry, or a server-side session. Those are product choices, not something the token format solves for you.

On an iOS client, the same caution applies: store tokens carefully, don’t log them, and don’t parse them to decide permissions the server has not granted. That habit sits alongside the other privacy work in privacy manifests and App Store review.

JWS versus JWE, and when encryption is needed

People say “JWT” when they mean at least two things:

If the claims are not sensitive — a user id, an expiry, a list of scopes the bearer is allowed to present — JWS is the usual choice, sent over HTTPS. HTTPS already encrypts the token in transit. The remaining risk is leftover tokens in logs, in browser history, in crash reports, and on a lost device. Don’t put a national insurance number, a password, or a card number in a JWS payload; the signature does not hide it.

Use JWE when the token will pass through a party that should not be able to read the claims, or when the payload itself is confidential even at rest with the client. That is a smaller set of designs than marketing language sometimes suggests. If you do use JWE, you still have to authenticate the token — encryption without integrity is not enough — and you should use a library that implements a modern combination, not a homemade XOR.

The JWE Decrypter & Encrypter is for inspecting a JWE you already have the key for, in a tool you run yourself. Don’t paste production tokens or keys into a website you do not control, including mine, if the token is still live.

Claims: iss, aud, exp, and the ones you should add

After the signature verifies, the claims are what you actually check. The registered names you should almost always treat as mandatory for an access token are:

Add your own claims sparingly, with names you control. A small API might include a cid for the client application, or a scope string. Don’t put a long, mutable authorisation matrix in the token if you will regret not being able to change it until expiry. Permissions that can be withdrawn should live on the server, or the token should be short-lived.

A decoded, unverified payload might look like this (and until you verify it, it is fiction):

{
  "iss": "https://auth.example.com",
  "aud": "https://api.example.com",
  "sub": "user_123",
  "exp": 1731261600,
  "iat": 1731258000,
  "scope": "tools:read"
}

The JWT Decoder will show you those fields. The decoder is a flashlight, not a gate. The JWT & JWKS Signature Verifier is the gate.

JWKS, kid and rotating keys

A JWKS (a JSON Web Key Set) is a JSON document, usually published at a stable HTTPS URL, that lists the public keys an issuer currently uses to sign tokens. A typical URL looks like https://auth.example.com/.well-known/jwks.json, or the jwks_uri advertised in an OpenID Provider configuration document.

A set looks like this:

{
  "keys": [
    {
      "kty": "RSA",
      "use": "sig",
      "alg": "RS256",
      "kid": "2026-09-key",
      "n": "…",
      "e": "AQAB"
    }
  ]
}

The token header usually includes a kid (key id) that tells the receiver which key in the set to use. That is a hint, not a command. The receiving server should:

  1. Fetch and cache the JWKS over HTTPS, from a URL it was configured with, not from a URL in the token.
  2. Pick a key that matches the kid, the expected key type, and the expected algorithm.
  3. Verify the signature with that key.
  4. If the kid is unknown, refetch the JWKS once (keys rotate), and if the key is still unknown, reject the token.
  5. Never “helpfully” fall back to any other key that happens to be in the cache, and never accept a key that is embedded in the token header (jwk or a x5c you did not pin).

Rotation is why JWKS exists. A careful issuer:

A receiver should cache the JWKS for a short time (minutes, not days) and honour cache headers with a cap, so a rotation is picked up. If you pin a single PEM in a config file and never refresh it, you will drop every user on the next rotation, or you will be unable to retire a key.

Symmetric algorithms (HS256 and friends) do not use a public JWKS in the same way: the issuer and the API share a secret. That is workable on a single server you control, and fragile as soon as more than one service needs to verify. Don’t advertise an HMAC secret in a JWKS as if it were a public key.

Verification steps a server must not skip

A library will do the cryptography; you still have to configure it. The sequence I want to see in a small API is:

  1. Parse the compact token as a token, rejecting strings with the wrong number of parts, or with a typ you do not accept, before you think about claims.
  2. Read the header only far enough to learn alg and kid. Compare alg to an allow-list you configured (RS256, ES256, and so on). Reject none. Reject an algorithm switch: if the key is an RSA public key, do not verify an HMAC “signature” with it. This is a well-known class of bug.
  3. Resolve the key from your configured JWKS or secret, as above, not from the token.
  4. Verify the signature (or decrypt and verify a JWE).
  5. Check iss, aud, exp, and nbf.
  6. Only then read sub and your own claims, and make the authorisation decision.

Do this in a well-tested library for your language. The JWT Encoder & Signer is useful for making a fixture in a test; it is not a reason to sign tokens in a browser with a private key you pasted into a form.

Time is part of verification. Servers should run NTP or an equivalent, because a clock that is ten minutes off will mint or reject tokens incorrectly. Clients should not be asked to check exp as the only security control; they can use it to decide when to refresh, but the API must check it again.

Access tokens, refresh tokens and storage on a client

An access token is what the client sends to the API, usually as Authorization: Bearer …. It should be short-lived. A refresh token is a longer-lived credential for getting a new access token from the authorisation server, not for calling the API.

On a server-side web app, both can live in HTTP-only, Secure, SameSite cookies, or the access token can be kept in memory and the refresh token in a cookie. On a single-page app, the least-bad default that is still common is to keep tokens out of localStorage if you can, use the OAuth 2.0 Authorization Code flow with PKCE, and let a small backend hold the refresh token. The OAuth 2.0 PKCE Flow Simulator walks through the PKCE dance; it is the right shape for a public client, including a native app.

On iOS, use the system browser or ASWebAuthenticationSession for the sign-in, store refresh tokens in the Keychain, and attach the access token to requests you already send carefully — the same family of work as retries with URLSession, often sitting alongside Swift concurrency when those requests are awaited. Don’t put a bearer token in a URL query, and don’t write it into application logs.

If you accept an access token, send it only over HTTPS, and don’t put it in a hidden form field that might be logged. If it leaks, it is valid until it expires, unless you have a revocation list. That is why short exp values and refresh are worth the extra request.

The usual ways this goes wrong

The failures I would look for in a review, before any talk of clever claims:

A token that “looks fine” in a decoder has not been checked. Treat the decoder’s output as a comment, and the verifier’s output as the result.

How to inspect a token without trusting it

When a request fails with 401, or a mobile client claims it is signed in but the API disagrees, inspect the token in a controlled way:

  1. Copy the compact string from the client only if you can treat it as compromised, or if it is a test token. Prefer a fixture generated with the JWT Encoder.
  2. Decode the header and payload with the JWT Decoder to see alg, kid, iss, aud and exp. This step explains; it does not approve.
  3. Verify against the issuer’s JWKS or your test key with the JWT & JWKS Signature Verifier. If this fails, stop. The claims are untrusted.
  4. Compare the claims to what the API is configured to accept. A valid signature on a token for a different aud should still be a 401.
  5. If the token is a JWE, decrypt it only in a tool or environment that already has the key, such as the JWE Decrypter run with a key you supplied, and then run the same claim checks.

Don’t paste a live production refresh token into a SaaS debugger. Mint a short-lived test token, or use logs that already redacted the string. Once a bearer token has been through a chat window, rotate it.

Key takeaways

If you want a second pair of eyes on an API’s token checks, tell me about it.

Frequently asked questions

What is the difference between a JWT and a JWKS URL?

A JWT is a token: a signed or encrypted set of claims, sent with a request. A JWKS URL is where the issuer publishes the public keys a server should use to verify those tokens. You configure the JWKS URL; you do not take it from the token.

Should I store permissions inside a JWT?

You can store coarse, slow-changing scopes in the token, if you are willing to wait until expiry (or to revoke) when they change. Permissions you might withdraw immediately belong on the server, or the access token should be short-lived and refreshed often.

How do I rotate signing keys without dropping users?

Add the new public key to the JWKS first, wait until verifiers have refreshed, start signing new tokens with the new kid, and only remove the old key after every token it signed has expired. Receivers should refetch the JWKS when they see an unknown kid.

What is the difference between JWS and JWE?

JWS signs the claims but leaves them readable. JWE encrypts the claims so only a holder of the key can read them. Most API access tokens are JWS, sent over HTTPS. Encryption is an extra step for a payload that must stay confidential from intermediaries.

Why is it unsafe to decode a JWT without verifying it?

Because the payload is just Base64-encoded JSON. Anyone can write a header and a payload and omit a valid signature, or use the none algorithm. If your code trusts admin: true from a decoded token, an attacker can send you that JSON and you will believe it.

Written by Lee Johnson. Published 10 November 2026.

All articles · Swift A–Z · Apps

Questions, or an app in mind?

Let's bring your ideas to life. Whether you're envisioning a game-changing Apple Watch app or a transformative iOS experience, I'm here to make it happen.

Start a project