JWT secret generator

A 256-bit HMAC signing secret for HS256, base64url encoded and generated entirely in your browser.

Options
G

Why 32 bytes, and not fewer

HS256 is HMAC with SHA-256. RFC 7518 section 3.2 requires a key at least as long as the hash output for the HMAC algorithms, which is 256 bits for HS256, 384 bits for HS384 and 512 bits for HS512. This is a requirement of the specification, not a recommendation, and a library that accepts a shorter key is being lenient rather than correct.

There is a ceiling too, and it is less well known. HMAC hashes any key longer than the hash function's block size down before use. For SHA-256 that block is 64 bytes, so a 100-byte secret is compressed to 32 bytes and is no stronger than a 64-byte one. Somewhere between 32 and 64 bytes you have all the security this construction can give you.

The output above is 32 random bytes rendered as 43 base64url characters with no padding. Base64url is used rather than standard base64 because the value routinely ends up in an environment variable, a URL or a header, and standard base64 contains a plus sign, which decodes to a space in form-encoded content.

HMAC algorithms and their key requirements
algHMAC functionMinimum keyNo gain above
HS256HMAC-SHA-25632 bytes64 bytes
HS384HMAC-SHA-38448 bytes128 bytes
HS512HMAC-SHA-51264 bytes128 bytes

A weak secret is broken offline, from one captured token

This is the failure that matters most and the one that is least understood. An attacker who obtains a single JWT has everything needed to attack the secret without touching your server again. The header and payload are base64url, not encryption, so they are already readable. The signature is HMAC-SHA-256 over those two segments. The attacker guesses a secret, computes the HMAC locally, and compares. There is no rate limit, no lockout, no log entry and no network round trip.

The rates involved are measured. Hashcat 6.2.6-851 on a single NVIDIA RTX 5090 does SHA2-256 at 28.35 GH/s. HMAC costs roughly two compression calls rather than one, so treat that as an upper bound on the guessing rate, and it is still tens of billions of candidates per second on one consumer card. Every wordlist ever published is exhausted in minutes.

So the framework example value, the string secret, your company name, and any phrase a person chose all fall immediately. Length does not rescue a human-chosen secret: a memorable twenty-character phrase carries a small fraction of the entropy its length suggests, and the attack is a dictionary attack, not a character-by-character search.

256 uniformly random bits is not reachable at any rate, from any hardware, for any budget. That is the whole reason to generate the value rather than choose it.

Never derive a JWT secret from something an attacker can guess at: the application name, the domain, an environment variable that also appears elsewhere, or a hash of any of those. The derivation does not add entropy; it only hides how little there was.

alg:none and algorithm confusion

JWS defines the value none for unsecured tokens, which have an empty signature. A verifier that reads the algorithm out of the token's own header and then honours it will accept a token with alg set to none and no signature at all. Every major library fixed this years ago, but it reappears reliably in hand-rolled verification code and in middleware written to be permissive.

Algorithm confusion is the same mistake with a better disguise. Suppose your service issues RS256 tokens, signed with a private key and verified with a public one. An attacker takes your public key, which is public, re-signs a forged token using HS256 with the public key bytes as the HMAC secret, and sends it. A verifier that picks the algorithm from the token header and passes in whatever it calls "the key" will compute HMAC over the public key and verify the forgery successfully.

The rule that closes both is the same. The verifier decides the algorithm, from its own configuration, and rejects anything else before it looks at the signature. Every library that gets this right requires an explicit algorithm list at the verification call, which is why the examples below always pass one.

The secret is bytes, and libraries disagree about that

HMAC operates on bytes. What you have above is a printable rendering of 32 bytes, and libraries differ on how they turn your configuration string back into key material. Some base64-decode it. Some take the UTF-8 bytes of the text exactly as written. Both behaviours are defensible and both are common.

Neither is insecure on its own, because 43 base64url characters carry the same 256 bits either way. The failure is disagreement: two services sharing one secret, one decoding it and one not, produce different keys and reject each other's tokens. The symptom is a signature mismatch that looks like a clock problem or a deployment problem for far longer than it should. Write down which interpretation you use, in the same place you store the secret.

One detail confirms which form a value came from. Thirty-two bytes is 256 bits, and 43 base64url characters hold 258, so the final character carries only 4 significant bits. Measurement over 20,000 samples per size confirms it: the base64 of any 32-byte value always ends in one of the sixteen characters 048AEIMQUYcgkosw. A 43-character value ending in anything else was not produced by encoding 32 bytes, and a strict decoder is entitled to reject it.

Rotating the secret without signing everyone out

Replacing the secret invalidates every token signed with the old one, so a straight swap logs out every active session at once. The fix is the kid header, which names the key a token was signed with.

Issue new tokens under the new key id while the verifier still accepts both. Once the longest token lifetime has passed, drop the old key. If your access tokens live fifteen minutes, the overlap can be under an hour; if they live a week, the overlap is a week, which is one of the better arguments for short access tokens and a separate refresh path.

The same mechanism handles compromise, in reverse. If the secret leaks you have to invalidate immediately and accept the sign-out, because every token ever issued under that key is now forgeable, including ones that have not expired.

Doing this in your own code

Shell
# 32 bytes as base64url, no padding: 43 characters, 256 bits.
openssl rand -base64 32 | tr '+/' '-_' | tr -d '='

# The same 256 bits as hex, if your config format dislikes non-alphanumerics.
openssl rand -hex 32

Both lines produce the same amount of key material. They differ only in how it is written down.

Node (jose)
import { SignJWT, jwtVerify } from "jose";

// The secret is BYTES. Decode what you stored rather than handing the
// library the printable text, unless the text itself is meant to be the key.
const secret = Buffer.from(process.env.JWT_SECRET, "base64url");

const token = await new SignJWT({ sub: "user_123" })
  .setProtectedHeader({ alg: "HS256" })
  .setIssuedAt()
  .setExpirationTime("15m")
  .sign(secret);

// Always pass algorithms. Left out, jose falls back to every algorithm
// the key could carry, which for a shared secret means HS256, HS384 and
// HS512. jose itself never accepts alg: "none", but a one-entry allow
// list is the habit that closes algorithm confusion everywhere else.
const { payload } = await jwtVerify(token, secret, { algorithms: ["HS256"] });
Python (PyJWT)
import jwt, os, base64

# 43 base64url characters need one "=" of padding before decoding.
secret = base64.urlsafe_b64decode(os.environ["JWT_SECRET"] + "=")

token = jwt.encode({"sub": "user_123"}, secret, algorithm="HS256")

# PyJWT requires algorithms=. Make it an explicit allow list of one.
claims = jwt.decode(token, secret, algorithms=["HS256"])
Go (golang-jwt v5)
import (
    "fmt"

    "github.com/golang-jwt/jwt/v5"
)

// jwt.Parse hands the callback the token's own header, so the callback has
// to refuse anything that is not the algorithm you actually issue.
tok, err := jwt.Parse(raw, func(t *jwt.Token) (interface{}, error) {
    if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
        return nil, fmt.Errorf("unexpected alg %v", t.Header["alg"])
    }
    return secret, nil
}, jwt.WithValidMethods([]string{"HS256"}))
PHP (firebase/php-jwt)
use Firebase\JWT\JWT;
use Firebase\JWT\Key;

$secret = base64_decode(strtr(getenv('JWT_SECRET'), '-_', '+/'));

$token = JWT::encode(['sub' => 'user_123', 'exp' => time() + 900], $secret, 'HS256');

// The algorithm travels with the key, so the token cannot pick one.
$claims = JWT::decode($token, new Key($secret, 'HS256'));

Questions

How long should a JWT secret be?

At least 32 bytes for HS256, which RFC 7518 section 3.2 states as a requirement rather than advice. Above 64 bytes there is no benefit, because HMAC hashes an over-length key down to the digest size first. Thirty-two random bytes is the right answer for almost every deployment.

Can I just use a long password as the secret?

No. The attack is an offline dictionary attack running at tens of billions of candidates per second on one GPU, and it needs only a single captured token to run. A phrase a person chose is enumerable regardless of how many characters it has. Generate the value.

Should I use HS256 or RS256?

HS256 when the same service both issues and verifies, because there is one secret and it is simpler. RS256 or EdDSA when verification happens somewhere that should not be able to mint tokens, since the verifier only needs the public key. Choosing asymmetric signing does not remove the confusion attack described above, so the verifier still has to pin the algorithm.

Is the JWT payload encrypted?

No. A signed JWT is base64url, which anyone holding the token can decode. The signature proves the payload was not altered; it does not hide it. Do not put anything private in a JWT. If you need confidentiality, that is JWE, which is a different construction.

Why base64url rather than hex or plain characters?

Base64url is compact, has no characters that need escaping in a URL, header or environment variable, and is the encoding JWTs already use throughout. Hex is equally safe and 50% longer. Avoid standard base64 here: it contains a plus sign, which decodes to a space in form-encoded content and silently changes the value.

Does this secret leave my browser?

No. It comes from crypto.getRandomValues() in the page and is never transmitted or logged. That is worth caring about for this value in particular, since it is the single key that forges any token in your system.

Related generators