JWT signing algorithms compared: HS256, RS256, ES256, PS256

The algorithm you pick for signing JWTs determines your key distribution model, your verification story at scale, and your exposure if a key is compromised. The four algorithms you will encounter in production are HS256 (HMAC-SHA256), RS256 (RSASSA-PKCS1-v1_5 with SHA-256), ES256 (ECDSA with P-256 and SHA-256), and PS256 (RSASSA-PSS with SHA-256). They have fundamentally different security properties, and choosing based on what the library defaults to rather than what your architecture needs creates real problems.

HS256: symmetric HMAC

HS256 signs and verifies using the same secret key. Any party with the key can both sign new tokens and verify existing ones. This creates a key distribution problem at scale: every service that needs to verify tokens must have access to the signing secret. If any of those services is compromised, the attacker can forge arbitrary tokens.

import jwt from 'jsonwebtoken';

const SECRET = process.env.JWT_SECRET;  // must be >= 256 bits (32 bytes)

// Signing
const token = jwt.sign(
  { sub: userId, org: orgId, role: 'member' },
  SECRET,
  { algorithm: 'HS256', expiresIn: '1h', issuer: 'bastionary' }
);

// Verification — any service with SECRET can do this
const payload = jwt.verify(token, SECRET, {
  algorithms: ['HS256'],  // always pin the algorithm
  issuer: 'bastionary',
});

HS256 is appropriate for single-service architectures where the same process that issues tokens also verifies them — a monolith, or a stateless SPA with a single backend. It is inappropriate for microservice architectures where token verification is distributed across services you do not fully control.

Never derive the HS256 key from a password or use a short key. NIST recommends keys of at least 256 bits for HMAC-SHA256. Use crypto.randomBytes(32) to generate the key and store it in a secrets manager, not an environment variable in source control.

RS256: asymmetric RSA

RS256 uses RSA key pairs. The private key signs tokens; the corresponding public key verifies them. Services only need the public key to verify — they cannot forge tokens. Public keys are typically distributed via a JWKS endpoint (/.well-known/jwks.json), making RS256 the natural fit for federated architectures.

import { SignJWT, importPKCS8, importSPKI, createRemoteJWKSet, jwtVerify } from 'jose';

// Signing with private key (auth server only)
const privateKey = await importPKCS8(process.env.RS256_PRIVATE_KEY, 'RS256');

const token = await new SignJWT({ sub: userId, org: orgId })
  .setProtectedHeader({ alg: 'RS256', kid: 'key-2022-07' })
  .setIssuedAt()
  .setExpirationTime('1h')
  .setIssuer('https://auth.yourapp.com')
  .sign(privateKey);

// Verification with JWKS (any downstream service)
const JWKS = createRemoteJWKSet(
  new URL('https://auth.yourapp.com/.well-known/jwks.json')
);

const { payload } = await jwtVerify(token, JWKS, {
  issuer: 'https://auth.yourapp.com',
  algorithms: ['RS256'],
});

RS256 with 2048-bit keys is the current baseline. 4096-bit keys provide a larger security margin but are measurably slower to sign — about 4x slower than 2048-bit on most hardware. Key rotation is straightforward: publish a new key in JWKS while leaving the old key present for the duration of outstanding token lifetimes, then remove it.

ES256: ECDSA on P-256

ES256 uses elliptic curve cryptography. A P-256 key pair provides roughly equivalent security to a 3072-bit RSA key while being dramatically smaller — the private key is 32 bytes versus 2048+ bytes for RSA. Signing and verification are faster. Token sizes are smaller because the signature is shorter.

# Generate ES256 key pair
openssl ecparam -name prime256v1 -genkey -noout -out ec-private.pem
openssl ec -in ec-private.pem -pubout -out ec-public.pem

# View key info
openssl ec -in ec-private.pem -text -noout
# read EC key
# Private-Key: (256 bit)
import { SignJWT, importPKCS8, createRemoteJWKSet, jwtVerify } from 'jose';

const privateKey = await importPKCS8(process.env.ES256_PRIVATE_KEY, 'ES256');

const token = await new SignJWT({ sub: userId })
  .setProtectedHeader({ alg: 'ES256', kid: 'ec-key-2022-07' })
  .setIssuedAt()
  .setExpirationTime('15m')  // ES256 suits short-lived tokens well
  .setIssuer('https://auth.yourapp.com')
  .sign(privateKey);

ES256 is the recommended algorithm for new systems. Performance at scale is meaningfully better than RS256: a P-256 signature takes roughly 0.1ms versus 1–2ms for RSA-2048 signing on typical hardware. For systems issuing millions of tokens per day, this matters.

One important nuance: ECDSA signatures are non-deterministic by default on some implementations — two signatures over the same data with the same key produce different outputs (due to the random nonce k). This is expected and correct. RFC 6979 defines deterministic ECDSA (used in some libraries) which removes this nonce but is not required by the standard.

PS256: RSA-PSS

PS256 uses the same RSA key infrastructure as RS256 but with RSASSA-PSS padding instead of PKCS1-v1_5. PSS is provably secure in the random oracle model; PKCS1-v1_5 has known theoretical weaknesses (though no practical attacks against RSA signing as of 2022). Some compliance frameworks (FIPS 186-5, certain government procurement requirements) mandate PSS over PKCS1.

import { SignJWT, importPKCS8 } from 'jose';

const privateKey = await importPKCS8(process.env.RSA_PRIVATE_KEY, 'PS256');

const token = await new SignJWT({ sub: userId })
  .setProtectedHeader({ alg: 'PS256', kid: 'rsa-pss-key-2022' })
  .setIssuedAt()
  .setExpirationTime('1h')
  .setIssuer('https://auth.yourapp.com')
  .sign(privateKey);

PS256 uses the same key sizes as RS256 (2048–4096 bit) and has similar performance characteristics. The main reason to choose PS256 over RS256 is compliance: if your customers require FIPS-validated implementations or your security policy mandates PSS padding, PS256 is the correct choice. Otherwise, ES256 is preferable due to performance and key size advantages.

Decision matrix

  • Single service / monolith: HS256 is fine. Keep the secret in a secrets manager. Rotate annually or on suspected compromise.
  • Microservices with distributed verification: ES256 or RS256. JWKS endpoint is mandatory. ES256 preferred for performance.
  • API gateway with external consumers: RS256 or ES256. External consumers expect JWKS. RS256 has broader library support in older clients.
  • Mobile apps (token on device): ES256. Smaller signature means smaller tokens, which matters at scale over mobile networks.
  • FIPS / government compliance: PS256 with 2048+ bit RSA keys.

Algorithm confusion attacks

Always explicitly specify the allowed algorithms when verifying. The infamous "alg: none" attack exploits libraries that accept a token claiming to be unsigned. A related attack swaps RS256 for HS256 and signs with the public key — if a library uses the public key as the HMAC secret when it sees HS256, the forged token verifies.

// WRONG: accepting any algorithm
jwt.verify(token, publicKey);

// CORRECT: pin the algorithm
jwt.verify(token, publicKey, { algorithms: ['RS256'] });

// Also verify the kid header to ensure you check the right key
const header = jwt.decode(token, { complete: true }).header;
const key = await getKeyByKid(header.kid);  // look up from JWKS cache
jwt.verify(token, key, { algorithms: ['RS256'] });

Bastionary issues ES256 tokens by default for all new applications, with JWKS published at a stable well-known endpoint. HS256 is available for applications that explicitly opt in to shared-secret mode, with the secret stored in Bastionary's encrypted key store rather than the application environment.

← Back to blog Try Bastionary free →