How to Generate JWT Tokens: HS256 vs RS256 Signing Compared
Every authentication system eventually needs to issue JSON Web Tokens. Libraries hide most of the complexity behind a single sign() call, but the choice of algorithm, the secret you pick and the claims you embed all have real security implications. This guide walks through how JWT generation actually works, compares HS256 with RS256, and shows the code for Node.js, Python and browser-based signing.
Anatomy of a Signed JWT
A JWT has three Base64URL-encoded parts joined with dots: header.payload.signature. Generation is a three-step process.
- Build a header JSON object with
alg(algorithm name likeHS256) andtyp: "JWT", then Base64URL encode it. - Build a payload JSON object with your claims (
sub,exp,iss, plus any custom claims) and Base64URL encode it. - Concatenate the two with a dot, sign that string with your chosen algorithm, Base64URL encode the signature and append it.
The signature proves that whoever generated the token held the secret (for HMAC algorithms) or the private key (for RSA and ECDSA). Tampering with any character in the header or payload invalidates the signature.
HMAC (HS256 / HS384 / HS512): Symmetric Signing
HMAC algorithms use a single shared secret for both signing and verification. Fast, simple, and perfectly fine when the service that issues the token is the same service that validates it. This is the common pattern for a monolithic app or a backend that both creates and reads its own sessions.
The trade-off is that every party who needs to verify the token also needs the full secret, which lets them forge new tokens. If you have multiple services verifying, any one compromise compromises all issuance.
Secret requirement: at least 256 bits (32 random bytes) for HS256, 384 bits for HS384, 512 bits for HS512. A dictionary word or a 10-character string will fail security audits and can be brute-forced offline once an attacker captures a single token.
// Generate a proper HS256 secret openssl rand -base64 32 // → sYz4hqjA9WPGbFtAU1mKp3k6HYBAg8H2mZO2xRqL8Vw=
RSA (RS256 / RS384 / RS512): Asymmetric Signing
RS256 uses a key pair. The private key signs, the public key verifies. This is the right choice when tokens are issued by one service but consumed by many, which is the standard OAuth 2.0 / OIDC pattern. Google, Auth0 and AWS Cognito all publish a public JSON Web Key Set (JWKS) so clients can fetch the public key and verify ID tokens without sharing any secrets.
The trade-off is more setup and more compute. An RSA-2048 signature takes roughly 10x longer to generate than an HMAC-SHA256 signature, though verification is still fast. You also need to manage key rotation, PEM parsing and JWKS endpoints.
ES256: Modern Elliptic-Curve Alternative
ES256 uses ECDSA on the P-256 curve. Same public/private key model as RS256, but with dramatically smaller keys (256-bit vs 2048-bit), smaller signatures and faster operations. Modern systems default to ES256 for new deployments. Support is universal in 2026.
Node.js: jsonwebtoken
import jwt from "jsonwebtoken";
const payload = {
sub: "user_123",
email: "alice@example.com",
role: "admin",
};
// HS256 (shared secret)
const hs = jwt.sign(payload, process.env.JWT_SECRET, {
algorithm: "HS256",
expiresIn: "15m",
issuer: "my-api",
});
// RS256 (private key)
import { readFileSync } from "fs";
const privateKey = readFileSync("./private.pem");
const rs = jwt.sign(payload, privateKey, {
algorithm: "RS256",
expiresIn: "15m",
issuer: "my-api",
});The expiresIn option automatically adds an exp claim. Always set it. Tokens that never expire are the most common JWT security mistake.
Python: PyJWT
import jwt, os, time
payload = {
"sub": "user_123",
"email": "alice@example.com",
"iss": "my-api",
"exp": int(time.time()) + 900, # 15 min
}
# HS256
token = jwt.encode(payload, os.environ["JWT_SECRET"], algorithm="HS256")
# RS256
with open("private.pem", "rb") as f:
private_key = f.read()
token = jwt.encode(payload, private_key, algorithm="RS256")Browser / Edge: Web Crypto API
If you are working in a Cloudflare Worker, a browser extension or a Deno/Bun function, you can sign JWTs without any library using the Web Crypto API. This is exactly how our JWT Generator tool works.
async function signHs256(payload, secret) {
const enc = (s) => new TextEncoder().encode(s);
const b64 = (b) =>
btoa(String.fromCharCode(...new Uint8Array(b)))
.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
const header = b64(enc(JSON.stringify({ alg: "HS256", typ: "JWT" })));
const body = b64(enc(JSON.stringify(payload)));
const input = header + "." + body;
const key = await crypto.subtle.importKey(
"raw", enc(secret),
{ name: "HMAC", hash: "SHA-256" },
false, ["sign"]
);
const sig = await crypto.subtle.sign("HMAC", key, enc(input));
return input + "." + b64(sig);
}Decision Matrix: When to Use Each Algorithm
| Use case | Recommended |
|---|---|
| Single monolith app, same service signs and verifies | HS256 |
| Microservices, multiple services verify | RS256 or ES256 |
| Public API, third-party integrations verify | RS256 or ES256 + JWKS |
| Short-lived session tokens on one backend | HS256 |
| Edge functions with low CPU budget | HS256 or ES256 |
Claims Every Token Should Have
- exp (expiration) — always set. 15 minutes for access tokens is a reasonable default.
- iat (issued at) — lets you enforce a maximum token age independent of exp, useful during key rotation.
- iss (issuer) — the service that signed the token. Verifiers should reject tokens with an unexpected issuer.
- aud (audience) — the service that should consume the token. Prevents tokens issued for one service from being accepted by another.
- sub (subject) — the user or entity the token is about.
- jti (JWT ID) — a unique ID if you need revocation. Store revoked jtis in a denylist.
Security Mistakes to Avoid
- Tokens that never expire. If a long-lived token leaks, the attacker has permanent access. Always set
exp. - Putting sensitive data in the payload. Payloads are encoded, not encrypted. Anyone with the token can read them. Never put passwords, full PAN, SSN or private keys in JWT claims.
- Accepting alg=none. The
nonealgorithm disables signature verification. Every modern library blocks it by default, but double-check your config. - Algorithm confusion. If your verifier accepts both HS256 and RS256, an attacker can take a public key, label it as HS256 and sign a forged token. Pin the exact algorithm in verification.
- Weak HMAC secrets. 256-bit minimum. Use
openssl rand -base64 32or our password generator.
Generate and test JWT tokens instantly
Our free JWT Generator signs tokens with HS256, HS384 or HS512 directly in your browser. Decode them with the JWT Debugger. No data leaves your device.
Open JWT Generator