Skip to main content
Back to BlogSecurity Guides

How to Generate a JWT (and Why the Payload Is Signed, Not Encrypted)

Sign a JSON Web Token with HS256 or RS256, pick the claims that actually do work, and avoid the one mistake every JWT intro skips: the payload is base64url, not encrypted. Anyone with the token can read it.

The Toolbox TeamAugust 16, 20267 min read

The token that looks encrypted and isn't

You're wiring up auth for an API and need a token — something the client carries on each request that proves who they are without a database lookup every time. A JWT fits. You can mint one in the browser right now: paste a payload, pick HS256, type a secret, and you have a three-part string ready to send as a Bearer header.

The middle segment looks like gibberish, so it reads as encrypted. It is base64url-encoded — a reversible encoding, not cryptography. Anyone who intercepts the token pastes it into a decoder and reads every claim. A JWT guarantees the token was not tampered with; it says nothing about who can read it. Get that wrong once and you ship a password in a payload.

Fastest path

Open the JWT Generator, pick HS256, type a secret, fill the payload, and the token builds itself as you type. With the canonical example header and payload:

Header   {"alg":"HS256","typ":"JWT"}
         → eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9

Payload  {"sub":"1234567890","name":"John Doe","iat":1516239022}
         → eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ

Token    <header>.<payload>.<signature>
         signature = HMAC-SHA256("<header>.<payload>", secret) → base64url

That is the whole interaction. The rest of this guide is the part the form can't decide for you: which algorithm, which claims, and what never goes in the payload.

The three parts, and the encoding that trips everyone

A JWT is header.payload.signature, each part base64url-encoded and joined by dots. Base64url is standard base64 with two substitutions — + becomes -, / becomes _ — and the trailing = padding stripped. That is why a JWT never contains +, /, or =. It has to be URL-safe, and those three characters break URLs.

The signature is the actually-cryptographic part. It is computed over the header and the payload together, not the payload alone — the signing input is the literal string base64url(header) + "." + base64url(payload). Change one character in the header and the signature no longer matches. That is the mechanism that makes tampering detectable: modify any claim, the signature breaks, a verifier that checks it rejects the token.

The payload, meanwhile, is just that same base64url. Decode it and you can read it. JWT is integrity, not confidentiality — restated as a rule: never put anything in the payload you wouldn't hand to a stranger.

HS256 vs RS256 — the one real decision

HS256 / 384 / 512 RS256 / 384 / 512
Key One shared secret Private key signs, public key verifies
Speed Fast Slower (RSA)
Trust model Anyone who can verify can also forge Verifiers can check without being able to sign
Use when One party issues and verifies A different party verifies, or many do

The rule is about who verifies. If your auth service mints tokens and your own API consumes them, HS256 is fine — one secret, kept on the server. The moment a third party, or a service you don't fully control, needs to verify your tokens, switch to RS256: you keep the private key, they get the public key, and they can confirm a token is yours without being able to mint one. The tool generates a 2048-bit RSA key pair in-browser and signs with the private half.

The number is the hash size — SHA-256, 384, 512. Pick 256 unless you have a specific reason for more; 512 doesn't make a token meaningfully harder to forge, just bigger.

The claims that actually do work

The registered claims are a defined set with three-letter keys. Most are optional, and most tutorials show two. The ones that earn their place:

  • exp — set it, always. Expiration, as a Unix timestamp in seconds (not JavaScript's milliseconds — Math.floor(Date.now()/1000), or your token expires in the year 5138). A token without exp is a permanent credential; if it leaks it is valid forever. Fifteen minutes to an hour for access tokens, days only for refresh tokens.
  • iss and aud — set both, validate both. Issuer ("who minted this") and audience ("who it's for"). The pair that lets a verifier reject a token minted for a different system. Without them, a token from staging works in production.
  • sub — the subject, as a stable opaque ID. The user's UUID, not their email. Emails change, and email is personally identifying data sitting in an unencrypted payload.
  • jti — a unique nonce per token. Use it when you need revocation or one-time-use semantics; store issued jti values and reject replayed ones.
  • iat — issued at, informational, helps spot reuse. Cheap to include. nbf (not before, delayed validity) is rarely needed — the tool has it as a checkbox.

Anything else goes in as a custom claim — role, permissions, scope — as JSON in the custom-claims box.

Gotchas

  • Never put a secret in the payload. A password, an API key, a card number — all readable by anyone with the token. Put the user ID in sub and look up the rest server-side.
  • The HMAC secret is not a password. secret, myapp123, changeme are all crackable — an attacker with one captured JWT brute-forces the HMAC offline, and short secrets fall in seconds. Use the tool's Generate button: 32 random bytes as hex for HS256 (48 for HS384, 64 for HS512), matching the hash output size. A human-typed string is not a signing key.
  • Pin the expected algorithm on verification. The classic JWT attack is alg: none — an empty signature with "alg":"none" in the header, relying on a verifier that trusts the header. Your verifier must reject any algorithm it didn't expect, by name. Never let the token tell you how to verify it.
  • Don't ship the HMAC secret in client code. A secret in a browser bundle is public. Signing belongs on the server; the in-browser generator is for dev and testing.
  • Timestamps are seconds, not milliseconds. exp and iat are seconds since the Unix epoch. JavaScript's Date.now() returns milliseconds — divide by 1000, or your expiry lands a thousand years in the future.

Summary

  • A JWT is header.payload.signature, base64url-encoded; the payload is signed, not encrypted — never store anything sensitive in it.
  • Pick HS256 when one party issues and verifies; RS256 when a separate or untrusted party needs to verify.
  • Always set exp (in seconds), set iss and aud and validate both, use a stable opaque ID for sub.
  • Use a 32-byte random secret for HS256 — not a password. The tool generates one.
  • Pin the expected algorithm on verification; never trust the token's own alg header.

Mint test tokens at the JWT Generator. To read a token back and check its expiry, the JWT Decoder is the counterpart. For password storage — a different job, hashing not signing — use the Bcrypt Generator; for the opaque IDs that belong in sub and jti, the UUID Generator.