Skip to Content

JSON Web Tokens

Signing and verifying tokens, algorithms, keys, claims and rotation.

A JWT is a signed statement such as "this request is from user 42, until 10:15". The server that issues it signs it; any server holding the right key can check it without a database lookup.

Creating an instance

createJwt returns a signer and verifier bound to one algorithm and one set of keys. Create it once and reuse it.

import sukuCrypto from '@sukujs/crypto';

const jwt = sukuCrypto.createJwt<{ role: 'user' | 'admin' }>({
  secret: env.JWT_SECRET,
  issuer: 'my-api',
  audience: 'my-web-app',
  expiresIn: '15m',
});

In a Suku app, make it a service so routes and middlewares can ask for it.

auth/tokens.ts
export const Tokens = service('tokens', {
  services: { env: Env },
  create: ({ env }) =>
    sukuCrypto.createJwt<{ role: 'user' | 'admin' }>({ secret: env.JWT_SECRET, issuer: 'my-api', expiresIn: '15m' }),
});

Signing

const token = await jwt.sign({ role: 'admin' }, { subject: user.id });

sign adds iat (issued at), exp (expiry), and the configured iss and aud.

Sign optionClaimMeaning
subjectsubWho the token is about, usually the user id.
expiresInexpOverrides the instance's lifetime for this token.
notBeforenbfThe token is rejected until this much time has passed.
jwtIdjtiA unique id, if you track individual tokens.

Durations are seconds, or strings such as 30s, 15m, 12h, 7d.

Verifying

import sukuCrypto, { JwtError } from '@sukujs/crypto';

try {
  const payload = await jwt.verify(token);
  payload.sub;    // "42"
  payload.role;   // "admin"
} catch (error) {
  if (error instanceof JwtError) log.debug('token rejected', { code: error.code });
  throw new UnauthorizedException();
}

verify returns the payload only if every check passes.

OrderCheckCode on failure
1Three well-formed parts, under 8,192 charactersmalformed
2The header's algorithm equals the configured onealgorithm_mismatch
3The signatureinvalid_signature
4exp is present and not passedinvalid_claim, expired
5nbf has passed, iat is not in the futurenot_active, invalid_claim
6Issuer and audience, when configuredinvalid_claim
7Your schema, when giveninvalid_claim

The algorithm is fixed when you create the instance and is never taken from the token. That is what defeats two classic attacks: tokens declaring alg: none, and tokens signed with HMAC using an RSA public key as the secret.

Reading the token from a request

import sukuCrypto from '@sukujs/crypto';

const token = sukuCrypto.bearerToken(ctx.headers.get('authorization'));   // undefined if absent or malformed

Choosing an algorithm

SituationAlgorithmKeys
One service signs and verifiesHS256 (the default)One shared secret.
Other services or third parties verifyES256 or EdDSAPrivate key signs, public key verifies.
An existing system requires RSARS256 or PS2562048-bit keys or larger.

With a shared secret, anything that can verify a token can also forge one. Use a key pair as soon as a second service needs to verify.

// The auth service signs.
const issuer = sukuCrypto.createJwt({ algorithm: 'ES256', privateKey: env.JWT_PRIVATE_KEY, publicKey: env.JWT_PUBLIC_KEY, issuer: 'auth' });

// Every other service only verifies, and holds no secret.
const verifier = sukuCrypto.createJwt({ algorithm: 'ES256', publicKey: env.JWT_PUBLIC_KEY, issuer: 'auth' });

Keys may be PEM strings, JWK objects or CryptoKeys. PEM must be PKCS#8 for private keys and SPKI for public keys.

# ES256 key pair
openssl ecparam -name prime256v1 -genkey -noout | openssl pkcs8 -topk8 -nocrypt -out private.pem
openssl ec -in private.pem -pubout -out public.pem

# Convert a legacy "BEGIN RSA PRIVATE KEY" file
openssl pkcs8 -topk8 -nocrypt -in legacy.pem -out private.pem

Typed and validated payloads

The type parameter describes the payload for TypeScript. A schema also checks it at runtime, which matters when tokens may come from an older version of your app.

const Claims = z.object({ sub: z.string(), role: z.enum(['user', 'admin']) });

const jwt = sukuCrypto.createJwt({ secret: env.JWT_SECRET, schema: Claims });
const { role } = await jwt.verify(token);   // 'user' | 'admin', checked

Options

OptionDefaultMeaning
algorithmHS256HS, RS, PS and ES at 256, 384 or 512, or EdDSA.
secretFor HS. At least 32, 48 or 64 bytes for HS256, HS384, HS512.
privateKey, publicKeyFor the other algorithms.
expiresIn1hfalse issues tokens that never expire and accepts them.
issuerWritten as iss, and required to match on verify.
audienceWritten as aud. On verify, any one configured value must match.
clockTolerance0Seconds of clock skew allowed between servers.
schemaA Standard Schema for the payload.
keyIdWritten to the header as kid.
previousSecrets, previousPublicKeysOld keys accepted for verifying only.
maxTokenLength8192Longer tokens are rejected before any work is done.

Rotating keys

Add the new key as the main one and move the old one to the "previous" list. New tokens are signed with the new key; old tokens keep verifying until they expire.

sukuCrypto.createJwt({ secret: env.JWT_SECRET, previousSecrets: [env.JWT_SECRET_OLD] });

Once the longest token lifetime has passed, remove the old key.

Inspecting a token

decodeJwtUnverified reads the header and payload without checking anything. Its output is attacker-controlled. It is for logging and debugging, never for a decision.

const { header, payload } = sukuCrypto.decodeJwtUnverified(token);

What a JWT cannot do

  • It cannot be revoked. It is valid until it expires. Keep access tokens short-lived.
  • It hides nothing. The payload is readable by anyone who has the token.
  • It does not replace a session store when you need to log a user out everywhere at once.

See use cases for a complete login flow that handles all three.

On this page