Three segments, and two of them are readable by anyone
A JWT is base64url(header) . base64url(payload) . base64url(signature). The first two parts are plain JSON with a reversible encoding on top. Paste the header from the sample above into any Base64 decoder and you get {"alg":"HS256","typ":"JWT"} back, character for character. The encoder above produces exactly eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9 for that header, the same string you see at the start of most tokens on the web.
The signature is the only part needing a key. It covers the first two segments joined by a dot, so changing one character of the payload breaks verification. It does nothing to hide the payload. Treat every claim as public, because anyone holding the token, including the browser extension your user installed last week, reads it in one line of code.
Two encoding details trip up hand-rolled encoders. Base64url swaps + and / for - and _ and drops the = padding. And the input to it is UTF-8 bytes, not a JavaScript string. Call btoa() on a payload holding "name": "Zoë" and the browser throws an InvalidCharacterError. This page encodes through TextEncoder first, so accented names and emoji in claims survive.
Which alg belongs in your header
The choice comes down to who signs and who verifies. Signature length is what you pay for it on every request.
| alg | Signing key | Verifying key | Signature in token | Pick it when |
|---|---|---|---|---|
HS256 | Shared secret | Same secret | 43 chars | One service issues and checks its own tokens |
RS256 | RSA private key | RSA public key | 342 chars (2048 bit) | Several services verify, one issuer signs, broad library support needed |
PS256 | RSA private key | RSA public key | 342 chars | Same as RS256 where a policy demands PSS padding (FAPI, some banks) |
ES256 | P-256 private key | P-256 public key | 86 chars | Asymmetric signing with short tokens, mobile and IoT clients |
none | None | None | 0 chars | Unit test fixtures. Never accepted by a server you run |
HS256 has one structural problem. Every service able to verify a token is also able to mint one, because both jobs use the same secret. Once a third service needs to check tokens, move to RS256 or ES256 and publish the public key at a JWKS endpoint. The verifiers then hold nothing worth stealing.
Run the same payload through RS256 twice above and the token stays identical. Switch to ES256 or PS256 and the signature changes on every run. Both are randomized by design, and every one of those signatures verifies. Snapshot tests comparing a whole ES256 token against a stored string will fail for this reason, so compare decoded claims instead.
Registered claims and the unit each one expects
RFC 7519 defines seven claim names. None are mandatory in the spec, and most libraries enforce some of them by default anyway.
exp,nbf,iat- NumericDate, meaning whole seconds since 1970 UTC.
Date.now()in JavaScript returns milliseconds, soexp: Date.now() + 3600produces a token valid for roughly 56,000 years. Divide by 1000 and floor. The checks panel flags any timestamp past the year 5000. iss- Who issued the token, usually a URL such as
https://toolexe.com. Verifiers compare it as an exact string, so a trailing slash on one side and not the other causes a rejection. aud- Who the token is for. A string or an array of strings. Libraries such as jose and PyJWT reject a token carrying
audwhen the verifier was not told which audience to expect. sub- The principal, normally a stable user ID. Use an opaque ID instead of an email address, since emails change and end up in logs.
jti- A unique token ID. Useful only when the server stores it, for revocation lists or replay checks on one-time tokens.
Everything else in the payload is a private claim. Keep those short. A token rides along on every request, and a cookie holding one is capped near 4 KB by browsers.
Matching this page from your backend
Here is the sample payload signed in three common libraries:
// Node, jose
import { SignJWT } from 'jose';
const key = new TextEncoder().encode(process.env.JWT_SECRET);
const token = await new SignJWT({ name: 'Leila Haddad', role: 'editor' })
.setProtectedHeader({ alg: 'HS256', typ: 'JWT' })
.setIssuer('https://toolexe.com')
.setSubject('usr_4821')
.setAudience('toolexe-dashboard')
.setIssuedAt()
.setExpirationTime('15m')
.sign(key);
# Python, PyJWT
import jwt, time
now = int(time.time())
token = jwt.encode(
{"iss": "https://toolexe.com", "sub": "usr_4821", "iat": now, "exp": now + 900},
secret, algorithm="HS256")
// PHP, firebase/php-jwt
use Firebase\JWT\JWT;
$token = JWT::encode(['sub' => 'usr_4821', 'iat' => time(), 'exp' => time() + 900], $secret, 'HS256');
Expect the tokens to differ from this page even with identical claims and secret, and expect every one of them to verify. JSON key order and whitespace go straight into the signed bytes. php-jwt writes its header as {"typ":"JWT","alg":"HS256"}, typ first, so its tokens start with eyJ0eXAiOiJKV1Qi instead of eyJhbGciOi. This page keeps keys in the order you typed them and strips whitespace. Put typ first in header.json and the first segment matches php-jwt byte for byte.
When the server answers 401
- Decode before you debug. Paste the rejected token into the JWT decoder and compare
alg,issandaudwith what the verifier expects. A mismatch there explains most failures without touching keys. - Check the clock. An
iatornbfa few seconds ahead of the server clock gets rejected by strict verifiers. Most libraries accept a leeway setting of 30 to 60 seconds. - Check how the secret is read. A secret generated with
openssl rand -base64 32is 32 random bytes written as 44 characters. If the server decodes it and this page does not, the keys differ. Tick the base64url box to match a server reading decoded bytes. - Check the key format. WebCrypto here and most modern libraries want PKCS#8 (
BEGIN PRIVATE KEY). Older OpenSSL output is PKCS#1 (BEGIN RSA PRIVATE KEY). Convert withopenssl pkcs8 -topk8 -nocrypt -in old.pem -out key.pem. - Check the header transport. The header is
Authorization: Bearer <token>with one space. A stray newline from a copied token, or quotes left around it in a shell variable, produce an invalid signature error with nothing wrong in the token itself.
What this encoder does not cover
- No encryption. Output is a signed JWS. For a payload hidden from the holder you need JWE (RFC 7516), which this page does not produce.
- No EdDSA or ES512. Browser support for Ed25519 and P-521 in WebCrypto is still uneven, so both are left out instead of failing on some visitors.
- PKCS#8 keys only. PKCS#1, SEC1 (
BEGIN EC PRIVATE KEY), encrypted PEM and JWK input are rejected with a message. Convert with openssl first. - No verification step. This page signs. Checking a token against a key belongs in the decoder or in your own test suite.
- Test keys, not production keys. Nothing leaves the tab, and you will see no request in the network panel while signing. A production secret pasted into any web page has still passed through your clipboard, so rotate one if it ends up here.