Signing

sign(payload, key, options?)

Produces a compact JWS — the familiar dotted three-part string.

Parameters

NameTypeRequired?
payloadstring | Uint8Array | Record<string, unknown>Yes
keyCryptoKey | JWSSignJWK | Uint8ArrayYes
options.algJWSAlgorithmWhen key has no alg hint
options.protectedHeaderJWSHeaderParameters (extra fields)No
options.expiresInnumber | string — e.g. "1h", "7D", 3600No
options.currentDateDateNo

JWSSignJWK narrows the JWK by family: JWK_oct<JWK_HMAC> for HMAC, or an asymmetric private JWK whose alg is in the matching signing family (RS*/PS* for RSA, ES* for ECDSA, Ed25519/EdDSA for OKP). A JWK whose alg points at a non-signing family ("RSA-OAEP", "A256KW", "ECDH-ES", …) is rejected at the type level.

Returns Promise<string> — the compact JWS.

Algorithm inference

When the key is a JWK with an alg field set, unjwt reads it and uses that algorithm. generateJWK() always sets alg for you, so in practice you rarely specify it by hand:

inferred.ts
const key = await generateJWK("HS256"); // key.alg === "HS256"
const token = await sign({ sub: "u1" }, key); // uses HS256 automatically

You must pass options.alg explicitly in three cases:

  • The key is a raw Uint8Array (no metadata to infer from).
  • The key is a CryptoKey without algorithm context (rare — generateKey() sets the algorithm internally).
  • The JWK has no alg field.
explicit.ts
import { base64UrlDecode } from "unjwt/utils";

const rawKey = base64UrlDecode("GawgguFyGrWKav7AX4VKUg", { returnAs: "uint8array" });
const token = await sign({ sub: "u1" }, rawKey, { alg: "HS256" });

expiresIn — setting exp declaratively

Sets the exp claim relative to iat:

await sign({ sub: "u1" }, key, { expiresIn: "1h" }); // exp = iat + 3600
await sign({ sub: "u1" }, key, { expiresIn: 30 }); // exp = iat + 30
await sign({ sub: "u1" }, key, { expiresIn: "7days" }); // exp = iat + 604800

Accepted forms: "30s", "10m", "2h", "7D", "1W", "3M", "1Y" and the long forms ("seconds", "minutes", "hours", "days", "weeks", "months", "years").

If your payload already has an iat, it's preserved; otherwise the current time is used. Use currentDate to override "now" in tests.

expiresIn is a signer-side option. On the verifier side, pass maxTokenAge or rely on the exp claim that's already baked into the token.

Custom header parameters

Anything passed in protectedHeader is merged into the JWS header. alg is reserved — it's always derived from the top-level alg option (or inferred from the key) and can't be overridden here. b64 is user-settable: set it to false to opt into the RFC 7797 unencoded-payload mode (see below).

custom-header.ts
const token = await sign({ sub: "u1" }, key, {
  protectedHeader: {
    kid: "legacy-2024", // overrides key.kid if set
    typ: "access+jwt", // RFC 8725 guidance — custom type name
    cty: "application/json", // nested-content media type
  },
});

kid fallback

If the key is a JWK with a kid field and you don't set protectedHeader.kid, unjwt adds kid to the header automatically. This is the "make tokens carry their key identity" behavior — essential for JWKS endpoints and key rotation. An explicit protectedHeader.kid always wins.

b64: false — RFC 7797 unencoded payload

RFC 7797 defines a variant where the JWS payload is signed over raw bytes rather than the base64url-encoded form. Useful when:

  • The payload is already a large or structured document (XML, JSON-LD) that consumers want to read without base64 decoding.
  • The payload contains binary data and base64url overhead matters.
unencoded.ts
const token = await sign("important document bytes", key, {
  protectedHeader: { b64: false, crit: ["b64"] },
});
// Result: "eyJhbGc...crit...b64...".<raw payload>.<signature>
// (note the payload is literally in the middle)

Always pair b64: false with crit: ["b64"] so verifiers that don't recognize the parameter reject the token instead of mis-validating it.

All signers of a multi-signature JWS must agree on b64. See Multi-signature.

Payload types

sign() accepts three payload shapes:

ShapeBehavior
Record<string, unknown>Serialized to JSON. typ: "JWT" added automatically if not set.
stringUTF-8 encoded as-is. No typ defaulting — you're not producing a JWT.
Uint8ArrayUsed as-is. No typ defaulting.

The type is inferred so verify() returns the same shape (see forceUint8Array if you want bytes back).

Full signature

interface JWSSignOptions {
  alg?: JWSAlgorithm;
  protectedHeader?: StrictOmit<JWSHeaderParameters, "alg"> & { alg?: never };
  currentDate?: Date;
  expiresIn?: ExpiresIn;
  expiresAt?: Date;
  notBeforeIn?: ExpiresIn;
  notBeforeAt?: Date;
}

expiresIn and expiresAt are mutually exclusive; notBeforeIn and notBeforeAt are likewise mutually exclusive. notBeforeIn: 0 is allowed and sets nbf = iat (an explicit temporal floor at sign time).

See also