Functional Weave
Code in TypeScript

auth.jwt

Sign, verify and decode HS256 JSON Web Tokens (RFC 7519): signature, algorithm and exp/nbf/iat checked with leeway.

1.0.0 · published 2026-10-03 by charlie · Anterra

Pinned by 54 tests, run in TypeScript, Python and Rust.signJwt 14 · verifyJwt 22 · decodeJwt 18

What it does

HS256 JSON Web Tokens: `signJwt` makes one, `verifyJwt` decides whether to trust one, and `decodeJwt` reads one without the key. They are a group because they share one token format and one JSON reader; install only what each side needs (`only=decodeJwt` in a browser, which never holds the secret).

token  = signJwt({"sub": "42", "exp": now + 3600}, secret)
result = verifyJwt(token, secret, now, 30)   # {valid, claims, error, message}
claims = decodeJwt(token)                    # no key; null if malformed

The functions

A group: 3 functions that work together, each in its own file, each pinned by its own tests in TypeScript, Python and Rust. A project can install only the ones it calls.

  1. signJwt (claims: record, secret: int[]) -> string
  2. verifyJwt (token: string, secret: int[], now: int, leewaySeconds: int) -> JwtVerification
  3. decodeJwt (token: string) -> record?

The types it declares, generated into your project

/** Whether a token may be trusted and, when it may, what it says. */
export interface JwtVerification {
  readonly valid: boolean;
  /** the payload, when valid */
  readonly claims: Readonly<Record<string, unknown>> | null;
  /** why not, when not valid */
  readonly error: JwtError | null;
  /** the reason in words, for logs rather than end users */
  readonly message: string | null;
}

export type JwtError = "malformed_token" | "unsupported_algorithm" | "invalid_signature" | "invalid_claims" | "token_expired" | "token_not_yet_valid" | "token_issued_in_future";

Once installed, your code imports each one from the group's module.

signJwt throws on bad input 14 tests

export function signJwt(claims: Readonly<Record<string, unknown>>, secret: readonly number[]): string
claimsrecorda JSON object; numbers must be whole and within 2^53, keys are written in sorted order
secretint[]at least 32 bytes (RFC 7518 section 3.2); encoding.utf8 for a text secret
returnsstringheader.payload.signature, each part base64url without padding

For example

  • signJwt(sub 42, name Ada Lovelace, iat 1,790,424,000, exp 1,790,427,600, jti c3VwZXItdW5pcXVl, ver 1, 97, 45, 115, 116, 114, 105, 110, 103, 45, 115, 101, 99, 114, 101, 116, 45, 97, 116, 4…) → eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3OTA0Mjc2MDAsImlhdCI6MTc5MDQyNDAwMCwianRpIjoiYzNWd1pYSXRkVzVwY1hWbCIsIm5hbWUiOiJBZGEgTG92ZWxhY2UiLCJzdWIiOiI0MiIsInZlciI6MX0.Ohi2Vy… a typical access token's claims
  • signJwt(z 1, a 2, m 3, 97, 45, 115, 116, 114, 105, 110, 103, 45, 115, 101, 99, 114, 101, 116, 45, 97, 116, 45, 108, 101, 97, 115, 116, 45, 50, 53, 54, 45, 98, 105, 116, 115, 45, 108, 111,…) → eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhIjoyLCJtIjozLCJ6IjoxfQ.2d-Ui9ktahBOXMUFlTIJz8Qj9znIFP5P2B8_L_zT3d0 keys given in any order are written sorted, so equal claims make equal tokens
  • signJwt(, 97, 45, 115, 116, 114, 105, 110, 103, 45, 115, 101, 99, 114, 101, 116, 45, 97, 116, 45, 108, 101, 97, 115, 116, 45, 50, 53, 54, 45, 98, 105, 116, 115, 45, 108, 111, 110, 103) → eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.e30.8VKCTiBegJPuPIZlp0wbV0Sbdn5BS6TE5DCx6oYNc5o an empty claims object
import { signJwt } from "#fune/auth.jwt@^1";
impl/typescript/sign_jwt.ts · 79 lines · open · raw

Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.

import { checkJwtSecret } from "./auth_jwt_decode_jwt.ts";  ← decodeJwt, another function of this group · built into the same file, even by a slim install
import { base64UrlEncode } from "./encoding_base64_base64_url_encode.ts";
import { utf8Encode } from "./encoding_utf8_utf8_encode.ts";
import { hmacSha256 } from "./crypto_hmac_sha256.ts";  ← from crypto.hmac-sha256 ^1.0.0 · built alongside by fune

/** base64url of {"alg":"HS256","typ":"JWT"}: the header is fixed, so it is written once. */
const HEADER = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9";
const MAX_DEPTH = 32;
const MAX_SAFE = 9007199254740991;

/** Code point order, which is how Python and Rust sort strings; JavaScript's default sort compares UTF-16 units. */
function compareCodePoints(a: string, b: string): number {
  const x = Array.from(a);
  const y = Array.from(b);
  for (let i = 0; i < x.length && i < y.length; i++) {
    const d = (x[i].codePointAt(0) as number) - (y[i].codePointAt(0) as number);
    if (d !== 0) return d;
  }
  return x.length - y.length;
}

function quote(text: string): string {
  let out = '"';
  for (let i = 0; i < text.length; i++) {
    const ch = text[i];
    const c = text.charCodeAt(i);
    if (ch === '"') out += '\\"';
    else if (ch === "\\") out += "\\\\";
    else if (ch === "\n") out += "\\n";
    else if (ch === "\r") out += "\\r";
    else if (ch === "\t") out += "\\t";
    else if (ch === "\b") out += "\\b";
    else if (ch === "\f") out += "\\f";
    else if (c < 0x20) out += "\\u" + c.toString(16).padStart(4, "0");
    else out += ch;
  }
  return out + '"';
}

/**
 * One canonical JSON text for a value: no spaces, object keys sorted by code
 * point, whole numbers only, non-ASCII written as UTF-8 rather than escaped.
 * Every language writes the same bytes, so the same claims sign to the same
 * token everywhere.
 */
function canonicalJson(value: unknown, depth: number): string {
  if (value === null) return "null";
  if (value === true) return "true";
  if (value === false) return "false";
  if (typeof value === "number") {
    if (!Number.isInteger(value) || Math.abs(value) > MAX_SAFE) {
      throw new RangeError("claims may only hold whole numbers from -(2^53 - 1) to 2^53 - 1");
    }
    return String(value === 0 ? 0 : value);
  }
  if (typeof value === "string") return quote(value);
  if (typeof value !== "object") throw new TypeError("claims must hold only JSON values");
  if (depth > MAX_DEPTH) throw new RangeError("claims are nested more than 32 deep");
  if (Array.isArray(value)) return "[" + value.map((item) => canonicalJson(item, depth + 1)).join(",") + "]";
  const entries = Object.keys(value as object)
    .sort(compareCodePoints)
    .map((key) => quote(key) + ":" + canonicalJson((value as Record<string, unknown>)[key], depth + 1));
  return "{" + entries.join(",") + "}";
}

/**
 * An HS256 JWT for the claims: header {"alg":"HS256","typ":"JWT"}, the
 * claims as canonical JSON, and the HMAC-SHA256 of the two under the secret.
 * Put exp (and usually iat, sub and jti) in the claims yourself, or use
 * auth.access-token, which does.
 */
export function signJwt(claims: Readonly<Record<string, unknown>>, secret: readonly number[]): string {
  checkJwtSecret(secret);
  if (typeof claims !== "object" || claims === null || Array.isArray(claims)) {
    throw new TypeError("claims must be a JSON object");
  }
  const signingInput = HEADER + "." + base64UrlEncode(utf8Encode(canonicalJson(claims, 1)));
  return signingInput + "." + base64UrlEncode(hmacSha256(secret, utf8Encode(signingInput)));
}

verifyJwt throws on bad input 22 tests

export function verifyJwt(token: string, secret: readonly number[], now: number, leewaySeconds: number): JwtVerification
tokenstringthe compact JWT, as parsed from the Authorization header
secretint[]the key it should be signed with, at least 32 bytes
nowintthe current time in Unix seconds, read by the caller
leewaySecondsintclock skew allowed on exp, nbf and iat, 0 or more; 30 to 60 is usual
returnsJwtVerificationvalid with the claims, or not valid with an error code; a bad token never throws

For example

  • verifyJwt(eyJ0eXAiOiJKV1QiLA0KICJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJqb2UiLA0KICJleHAiOjEzMDA4MTkzODAsDQogImh0dHA6Ly9leGFtcGxlLmNvbS9pc19yb290Ijp0cnVlfQ.dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk…) → valid true, claims …, error —, message — RFC 7515 appendix A.1: the example token, one second before exp
  • verifyJwt(eyJ0eXAiOiJKV1QiLA0KICJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJqb2UiLA0KICJleHAiOjEzMDA4MTkzODAsDQogImh0dHA6Ly9leGFtcGxlLmNvbS9pc19yb290Ijp0cnVlfQ.dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk…) → valid false, claims —, error token_expired, message the token has expired RFC 7515 appendix A.1: at exp exactly the token has expired (now must be before exp)
  • verifyJwt(eyJ0eXAiOiJKV1QiLA0KICJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJqb2UiLA0KICJleHAiOjEzMDA4MTkzODAsDQogImh0dHA6Ly9leGFtcGxlLmNvbS9pc19yb290Ijp0cnVlfQ.dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk…) → valid true, claims …, error —, message — RFC 7515 appendix A.1: 60 seconds of leeway still accepts it at exp + 59
import { verifyJwt } from "#fune/auth.jwt@^1";
impl/typescript/verify_jwt.ts · 58 lines · open · raw

Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.

import { checkJwtSecret, splitJwt } from "./auth_jwt_decode_jwt.ts";  ← decodeJwt, another function of this group · built into the same file, even by a slim install
import { type JwtError, type JwtVerification } from "./auth_jwt_types.ts";
import { hmacSha256 } from "./crypto_hmac_sha256.ts";  ← from crypto.hmac-sha256 ^1.0.0 · built alongside by fune
import { constantTimeEqual } from "./crypto_constant_time_equal.ts";  ← from crypto.constant-time-equal ^1.0.0 · built alongside by fune
import { utf8Encode } from "./encoding_utf8_utf8_encode.ts";

function fail(error: JwtError, message: string): JwtVerification {
  return { valid: false, claims: null, error, message };
}

/**
 * Check an HS256 token: its shape, that it says HS256 (so "none" and
 * algorithm-confusion tokens are refused before any key is used), its
 * signature in constant time, then exp, nbf and iat against `now` with
 * leeway. A bad token is an answer, never an exception; only a bad secret,
 * `now` or leeway (the caller's own mistake) throws.
 */
export function verifyJwt(token: string, secret: readonly number[], now: number, leewaySeconds: number): JwtVerification {
  checkJwtSecret(secret);
  if (typeof now !== "number" || !Number.isInteger(now)) {
    throw new TypeError("now must be a whole number of Unix seconds");
  }
  if (typeof leewaySeconds !== "number" || !Number.isInteger(leewaySeconds) || leewaySeconds < 0) {
    throw new RangeError("leewaySeconds must be a whole number, 0 or more");
  }

  const parts = splitJwt(token);
  if (parts === null) return fail("malformed_token", "the token is not a well-formed JWT");
  if (parts.header.alg !== "HS256") return fail("unsupported_algorithm", "only HS256 tokens are accepted");
  if (Object.prototype.hasOwnProperty.call(parts.header, "crit")) {
    return fail("unsupported_algorithm", "the token requires header extensions (crit) this verifier does not support");
  }

  const expected = hmacSha256(secret, utf8Encode(parts.signingInput));
  if (!constantTimeEqual(expected, parts.signature)) {
    return fail("invalid_signature", "the token's signature does not match");
  }

  const claims = parts.claims;
  for (const name of ["exp", "nbf", "iat"]) {
    if (Object.prototype.hasOwnProperty.call(claims, name) && typeof claims[name] !== "number") {
      return fail("invalid_claims", `the token's ${name} claim is not a number`);
    }
  }
  const has = (name: string): boolean => Object.prototype.hasOwnProperty.call(claims, name);
  // RFC 7519 4.1.4: the current time MUST be before exp.
  if (has("exp") && now >= (claims.exp as number) + leewaySeconds) {
    return fail("token_expired", "the token has expired");
  }
  // RFC 7519 4.1.5: the current time MUST be at or after nbf.
  if (has("nbf") && now + leewaySeconds < (claims.nbf as number)) {
    return fail("token_not_yet_valid", "the token is not valid yet");
  }
  if (has("iat") && (claims.iat as number) > now + leewaySeconds) {
    return fail("token_issued_in_future", "the token was issued in the future");
  }
  return { valid: true, claims, error: null, message: null };
}

decodeJwt 18 tests

export function decodeJwt(token: string): Readonly<Record<string, unknown>> | null
tokenstringa compact JWT
returnsrecord?its claims WITHOUT checking the signature, for a browser to read exp or name; null if malformed

For example

  • decodeJwt(eyJ0eXAiOiJKV1QiLA0KICJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJqb2UiLA0KICJleHAiOjEzMDA4MTkzODAsDQogImh0dHA6Ly9leGFtcGxlLmNvbS9pc19yb290Ijp0cnVlfQ.dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk) → iss joe, exp 1,300,819,380, http://example.com/is_root true RFC 7515 appendix A.1: the example token's claims
  • decodeJwt(eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3OTA0Mjc2MDAsImlhdCI6MTc5MDQyNDAwMCwianRpIjoiYzNWd1pYSXRkVzVwY1hWbCIsIm5hbWUiOiJBZGEgTG92ZWxhY2UiLCJzdWIiOiI0MiIsInZlciI6MX0.Ohi2Vy…) → sub 42, name Ada Lovelace, iat 1,790,424,000, exp 1,790,427,600, jti c3VwZXItdW5pcXVl, ver 1 a token signJwt made
  • decodeJwt(eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3OTA0Mjc2MDAsImlhdCI6MTc5MDQyNDAwMCwianRpIjoiYzNWd1pYSXRkVzVwY1hWbCIsIm5hbWUiOiJBZGEgTG92ZWxhY2UiLCJzdWIiOiIxIiwidmVyIjoxfQ.Ohi2VyS…) → sub 1, name Ada Lovelace, iat 1,790,424,000, exp 1,790,427,600, jti c3VwZXItdW5pcXVl, ver 1 the signature is not checked: tampered claims still decode
import { decodeJwt } from "#fune/auth.jwt@^1";
impl/typescript/decode_jwt.ts · 123 lines · open · raw
import { base64UrlDecode } from "./encoding_base64_base64_url_decode.ts";
import { utf8Decode } from "./encoding_utf8_utf8_decode.ts";

/** Containers nested deeper than this are refused, in every language, rather than left to each parser's own limit. */
const MAX_DEPTH = 32;
const MAX_SAFE = 9007199254740991;

/**
 * Is this one canonical, unpadded base64url segment? Checked before decoding
 * so that a hostile token is an answer (null), never a thrown error, and so
 * a segment has exactly one spelling (the unused low bits must be zero).
 */
function isSegment(text: string, allowEmpty: boolean): boolean {
  if (text.length === 0) return allowEmpty;
  if (text.length % 4 === 1) return false;
  let last = 0;
  for (let i = 0; i < text.length; i++) {
    const c = text.charCodeAt(i);
    let v: number;
    if (c >= 65 && c <= 90) v = c - 65;
    else if (c >= 97 && c <= 122) v = c - 71;
    else if (c >= 48 && c <= 57) v = c + 4;
    else if (c === 45) v = 62;
    else if (c === 95) v = 63;
    else return false;
    last = v;
  }
  if (text.length % 4 === 2) return (last & 15) === 0;
  if (text.length % 4 === 3) return (last & 3) === 0;
  return true;
}

function hasLoneSurrogate(text: string): boolean {
  for (let i = 0; i < text.length; i++) {
    const c = text.charCodeAt(i);
    if (c >= 0xd800 && c <= 0xdbff) {
      const next = i + 1 < text.length ? text.charCodeAt(i + 1) : 0;
      if (next >= 0xdc00 && next <= 0xdfff) {
        i++;
        continue;
      }
      return true;
    }
    if (c >= 0xdc00 && c <= 0xdfff) return true;
  }
  return false;
}

/**
 * JSON.parse accepts some things Python's and Rust's parsers answer
 * differently: integers beyond 2^53 (silently rounded here), overflowing
 * exponents (Infinity), escaped lone surrogates and unbounded nesting. Each
 * is refused in all three, so a token decodes the same everywhere.
 */
function isPortable(value: unknown, depth: number): boolean {
  if (typeof value === "number") {
    return Number.isFinite(value) && (!Number.isInteger(value) || Math.abs(value) <= MAX_SAFE);
  }
  if (typeof value === "string") return !hasLoneSurrogate(value);
  if (value === null || typeof value === "boolean") return true;
  if (depth > MAX_DEPTH) return false;
  if (Array.isArray(value)) return value.every((item) => isPortable(item, depth + 1));
  for (const [key, item] of Object.entries(value as Record<string, unknown>)) {
    if (hasLoneSurrogate(key) || !isPortable(item, depth + 1)) return false;
  }
  return true;
}

function parseObject(segment: string): Record<string, unknown> | null {
  let parsed: unknown;
  try {
    parsed = JSON.parse(utf8Decode(base64UrlDecode(segment)));
  } catch {
    return null;
  }
  if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return null;
  return isPortable(parsed, 1) ? (parsed as Record<string, unknown>) : null;
}

/**
 * A compact JWT split into its decoded parts, or null when it is not three
 * canonical base64url segments whose first two are JSON objects. Nothing is
 * verified here; verifyJwt builds on it.
 */
export function splitJwt(token: string): { header: Record<string, unknown>; claims: Record<string, unknown>; signingInput: string; signature: readonly number[] } | null {
  if (typeof token !== "string") return null;
  const parts = token.split(".");
  if (parts.length !== 3) return null;
  if (!isSegment(parts[0], false) || !isSegment(parts[1], false) || !isSegment(parts[2], true)) return null;
  const header = parseObject(parts[0]);
  const claims = parseObject(parts[1]);
  if (header === null || claims === null) return null;
  return { header, claims, signingInput: parts[0] + "." + parts[1], signature: base64UrlDecode(parts[2]) };
}

/**
 * The secret check shared by signJwt and verifyJwt. RFC 7518 section 3.2:
 * an HS256 key MUST be at least as long as the hash, 256 bits.
 */
export function checkJwtSecret(secret: readonly number[]): void {
  if (!Array.isArray(secret) && !(secret instanceof Uint8Array)) {
    throw new TypeError("secret must be a list of integers from 0 to 255");
  }
  for (let i = 0; i < secret.length; i++) {
    const b = secret[i];
    if (typeof b !== "number" || !Number.isInteger(b) || b < 0 || b > 255) {
      throw new RangeError("secret must be a list of integers from 0 to 255");
    }
  }
  if (secret.length < 32) {
    throw new RangeError(`secret must be at least 32 bytes (RFC 7518 section 3.2), received ${secret.length}`);
  }
}

/**
 * The claims of a JWT WITHOUT checking its signature, or null when it is
 * malformed. For a browser that holds its own token and wants to read `exp`
 * or `name`; never for deciding whether to trust a token.
 */
export function decodeJwt(token: string): Readonly<Record<string, unknown>> | null {
  const parts = splitJwt(token);
  return parts === null ? null : parts.claims;
}

Install

fune build

With that line in your source, in a TypeScript project (language typescript in fune.project), fune build resolves it and its 4 dependencies, pins them in fune.lock, downloads only the TypeScript package of each, and builds the code above into your project’s .fune/build, one readable file per capability with a header linking back here. Or pin a range in fune.project and build in one step:

fune add auth.jwt

That builds the whole group. To build only what you call, and whatever it uses inside the group:

fune add auth.jwt --only signJwt
Download for TypeScript auth.jwt-1.0.0-typescript.fune · 46,291 bytes sha256 abf6f5b9656637277eddf649f6b086e6c0a931b3f994bc1f3dc8a690057bd3fc

The manifest, vectors and README with only the TypeScript implementation. Install it without the registry with fune add ./auth.jwt-1.0.0-typescript.fune, or fetch it from a terminal with fune pull auth.jwt@1.0.0:typescript.

The whole function, every language, is one file too: auth.jwt-1.0.0.fune, 76,496 bytes, sha256 d49b933c8a31983881fb84fd097f957edda9112aa4ef3ceab659720a82a5849e. It installs into a project of any language.

Customise it in your app

The seams this capability offers. Put a marker directly above a function of your own and fune build wires it into the built code; the package on the registry is not changed, the built file’s header lists it under CUSTOMISED, and fune hooks lists every hook in the project. How hooks work.

before — your function gets the arguments and returns them, changed or not, or throws to refuse the call.

// fune: before auth.jwt.signJwt
// fune: before auth.jwt.verifyJwt
// fune: before auth.jwt.decodeJwt

after — your function gets the result and the arguments, and returns the final result.

// fune: after auth.jwt.signJwt
// fune: after auth.jwt.verifyJwt
// fune: after auth.jwt.decodeJwt

replace — inside this capability’s code only, calls to a dependency go to your function, with the same signature. Other capabilities that use it are unaffected; write in * to replace it everywhere.

// fune: replace crypto.constant-time-equal in auth.jwt
// fune: replace crypto.hmac-sha256 in auth.jwt
// fune: replace encoding.base64 in auth.jwt
// fune: replace encoding.utf8 in auth.jwt

step — your function runs at a numbered point inside a function’s body, receives the in-scope values it names as parameters, and may return replacements. List the points with fune show auth.jwt --steps.

// fune: step auth.jwt.<fn> after <n|label>

Tests

A version published now needs at least 8 tests for every function, and one that expects the error for each function that throws; the registry refuses it otherwise. fune verify --all runs each case in TypeScript, Python and Rust, and a project runs them again with fune verify. This page lists the cases; it does not run them. The exact JSON is vectors.json.

signJwt 14 tests

CaseArgumentsExpected
a typical access token's claims sub 42, name Ada Lovelace, iat 1,790,424,000, exp 1,790,427,600, jti c3VwZXItdW5pcXVl, ver 1, 97, 45, 115, 116, 114, 105, 110, 103, 45, 115, 101, 99, 114, 101, 116, 45, 97, 116, 4… → eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3OTA0Mjc2MDAsImlhdCI6MTc5MDQyNDAwMCwianRpIjoiYzNWd1pYSXRkVzVwY1hWbCIsIm5hbWUiOiJBZGEgTG92ZWxhY2UiLCJzdWIiOiI0MiIsInZlciI6MX0.Ohi2Vy…
keys given in any order are written sorted, so equal claims make equal tokens z 1, a 2, m 3, 97, 45, 115, 116, 114, 105, 110, 103, 45, 115, 101, 99, 114, 101, 116, 45, 97, 116, 45, 108, 101, 97, 115, 116, 45, 50, 53, 54, 45, 98, 105, 116, 115, 45, 108, 111,… → eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhIjoyLCJtIjozLCJ6IjoxfQ.2d-Ui9ktahBOXMUFlTIJz8Qj9znIFP5P2B8_L_zT3d0
an empty claims object , 97, 45, 115, 116, 114, 105, 110, 103, 45, 115, 101, 99, 114, 101, 116, 45, 97, 116, 45, 108, 101, 97, 115, 116, 45, 50, 53, 54, 45, 98, 105, 116, 115, 45, 108, 111, 110, 103 → eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.e30.8VKCTiBegJPuPIZlp0wbV0Sbdn5BS6TE5DCx6oYNc5o
nested objects and arrays, sorted at every level roles admin, user, profile …, active true, deleted —, 97, 45, 115, 116, 114, 105, 110, 103, 45, 115, 101, 99, 114, 101, 116, 45, 97, 116, 45, 108, 101, 97, 115, 116, 45, 50, 53, 5… → eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhY3RpdmUiOnRydWUsImRlbGV0ZWQiOm51bGwsInByb2ZpbGUiOnsibGFuZyI6ImVuIiwidGhlbWUiOiJkYXJrIn0sInJvbGVzIjpbImFkbWluIiwidXNlciJdfQ.4fLvEPiIqWWX2bO…
non-ASCII is written as UTF-8, not \u escapes name Zoë Łukasz 日本, 97, 45, 115, 116, 114, 105, 110, 103, 45, 115, 101, 99, 114, 101, 116, 45, 97, 116, 45, 108, 101, 97, 115, 116, 45, 50, 53, 54, 45, 98, 105, 116, 115, 45, 108,… → eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjoiWm_DqyDFgXVrYXN6IOaXpeacrCJ9.iTNgXXEa7vzonTGJwFoDU9FeF39moGS4sOxy4vtfQvY
quotes, backslashes and control characters are escaped the JSON way note say "hi"\ , 97, 45, 115, 116, 114, 105, 110, 103, 45, 115, 101, 99, 114, 101, 116, 45, 97, 116, 45, 108, 101, 97, 115, 116, 45, 50, 53, 54, 45, 98, 105, 116, 115, 45, 108, … → eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJub3RlIjoic2F5IFwiaGlcIlxcXG5cdFx1MDAwMSJ9.0facH1f7Xt_3FZvr7KfVCvJhnGlzvayMbBrIk9UUN2o
keys sort by code point: U+FF01 before an emoji, which UTF-16 order gets backwards 😀 1, ! 2, 97, 45, 115, 116, 114, 105, 110, 103, 45, 115, 101, 99, 114, 101, 116, 45, 97, 116, 45, 108, 101, 97, 115, 116, 45, 50, 53, 54, 45, 98, 105, 116, 115, 45, 108, 111, 110… → eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyLvvIEiOjIsIvCfmIAiOjF9.Y3s5mOsOg8DcrEgACfh9lcX3WJQ1k1jJNxA_GqDoV0Q
the RFC 7515 key signs the RFC's claims to a different, canonical token iss joe, exp 1,300,819,380, http://example.com/is_root true, 3, 35, 53, 75, 43, 15, 165, 188, 131, 126, 6, 101, 119, 123, 166, 143, 90, 179, 40, 230, 240, 84, 201, 40, 169, 15, 13… → eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjEzMDA4MTkzODAsImh0dHA6Ly9leGFtcGxlLmNvbS9pc19yb290Ijp0cnVlLCJpc3MiOiJqb2UifQ.tu77b1J0ZCHMDd3tWZm36iolxZtBRaArSrtayOBDO34
negative and zero numbers a -5, b 0, 97, 45, 115, 116, 114, 105, 110, 103, 45, 115, 101, 99, 114, 101, 116, 45, 97, 116, 45, 108, 101, 97, 115, 116, 45, 50, 53, 54, 45, 98, 105, 116, 115, 45, 108, 111, 110… → eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhIjotNSwiYiI6MH0.F2dwrxljP90TApwtxG2T4ArY1T2xO0NOyN8UWpXCtKg
a secret shorter than 32 bytes is refused sub 1, 116, 111, 111, 45, 115, 104, 111, 114, 116, 45, 115, 101, 99, 114, 101, 116 → error: secret must be at least 32 bytes (RFC 7518 section 3.2), received 16
Show the other 4 tests
CaseArgumentsExpected
claims must be an object, not a list 1, 2, 97, 45, 115, 116, 114, 105, 110, 103, 45, 115, 101, 99, 114, 101, 116, 45, 97, 116, 45, 108, 101, 97, 115, 116, 45, 50, 53, 54, 45, 98, 105, 116, 115, 45, 108, 111, 110, 103 → error: claims must be a JSON object
a fractional number exp 1,790,427,600.5, 97, 45, 115, 116, 114, 105, 110, 103, 45, 115, 101, 99, 114, 101, 116, 45, 97, 116, 45, 108, 101, 97, 115, 116, 45, 50, 53, 54, 45, 98, 105, 116, 115, 45, 108… → error: claims may only hold whole numbers
an integer beyond 2^53, which JavaScript cannot hold exactly id 9,007,199,254,740,992, 97, 45, 115, 116, 114, 105, 110, 103, 45, 115, 101, 99, 114, 101, 116, 45, 97, 116, 45, 108, 101, 97, 115, 116, 45, 50, 53, 54, 45, 98, 105, 116, 115, 45… → error: claims may only hold whole numbers
a secret byte out of range a 1, 256, 256, 256, 256, 256, 256, 256, 256, 256, 256, 256, 256, 256, 256, 256, 256, 256, 256, 256, 256, 256, 256, 256, 256, 256, 256, 256, 256, 256, 256, 256, 256 → error: secret must be a list of integers from 0 to 255

verifyJwt 22 tests

CaseArgumentsExpected
RFC 7515 appendix A.1: the example token, one second before exp eyJ0eXAiOiJKV1QiLA0KICJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJqb2UiLA0KICJleHAiOjEzMDA4MTkzODAsDQogImh0dHA6Ly9leGFtcGxlLmNvbS9pc19yb290Ijp0cnVlfQ.dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk… → valid true, claims …, error —, message —
RFC 7515 appendix A.1: at exp exactly the token has expired (now must be before exp) eyJ0eXAiOiJKV1QiLA0KICJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJqb2UiLA0KICJleHAiOjEzMDA4MTkzODAsDQogImh0dHA6Ly9leGFtcGxlLmNvbS9pc19yb290Ijp0cnVlfQ.dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk… → valid false, claims —, error token_expired, message the token has expired
RFC 7515 appendix A.1: 60 seconds of leeway still accepts it at exp + 59 eyJ0eXAiOiJKV1QiLA0KICJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJqb2UiLA0KICJleHAiOjEzMDA4MTkzODAsDQogImh0dHA6Ly9leGFtcGxlLmNvbS9pc19yb290Ijp0cnVlfQ.dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk… → valid true, claims …, error —, message —
RFC 7515 appendix A.1: under the wrong key the signature does not match eyJ0eXAiOiJKV1QiLA0KICJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJqb2UiLA0KICJleHAiOjEzMDA4MTkzODAsDQogImh0dHA6Ly9leGFtcGxlLmNvbS9pc19yb290Ijp0cnVlfQ.dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk… → valid false, claims —, error invalid_signature, message the token's signature does not match
a token signJwt made, halfway through its hour eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3OTA0Mjc2MDAsImlhdCI6MTc5MDQyNDAwMCwianRpIjoiYzNWd1pYSXRkVzVwY1hWbCIsIm5hbWUiOiJBZGEgTG92ZWxhY2UiLCJzdWIiOiI0MiIsInZlciI6MX0.Ohi2Vy… → valid true, claims …, error —, message —
claims changed after signing (sub 42 made 1) fail the signature eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3OTA0Mjc2MDAsImlhdCI6MTc5MDQyNDAwMCwianRpIjoiYzNWd1pYSXRkVzVwY1hWbCIsIm5hbWUiOiJBZGEgTG92ZWxhY2UiLCJzdWIiOiIxIiwidmVyIjoxfQ.Ohi2VyS… → valid false, claims —, error invalid_signature, message the token's signature does not match
alg none with no signature is refused before any key is used eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJleHAiOjE3OTA0Mjc2MDAsImlhdCI6MTc5MDQyNDAwMCwianRpIjoiYzNWd1pYSXRkVzVwY1hWbCIsIm5hbWUiOiJBZGEgTG92ZWxhY2UiLCJzdWIiOiI0MiIsInZlciI6MX0., 97, 4… → valid false, claims —, error unsupported_algorithm, message only HS256 tokens are accepted
an RS256 header is refused, whatever the signature eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3OTA0Mjc2MDAsImlhdCI6MTc5MDQyNDAwMCwianRpIjoiYzNWd1pYSXRkVzVwY1hWbCIsIm5hbWUiOiJBZGEgTG92ZWxhY2UiLCJzdWIiOiI0MiIsInZlciI6MX0.fOeyYK… → valid false, claims —, error unsupported_algorithm, message only HS256 tokens are accepted
a crit header naming extensions this verifier does not know eyJhbGciOiJIUzI1NiIsImNyaXQiOlsiZXhwIl19.eyJleHAiOjE3OTA0Mjc2MDAsImlhdCI6MTc5MDQyNDAwMCwianRpIjoiYzNWd1pYSXRkVzVwY1hWbCIsIm5hbWUiOiJBZGEgTG92ZWxhY2UiLCJzdWIiOiI0MiIsInZlciI6MX0.wk… → valid false, claims —, error unsupported_algorithm, message the token requires header extensions (crit) this verifier does not support
nbf in the future beyond the leeway eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYmYiOjE3OTA0MjYwMDAsInN1YiI6IjQyIn0.iv6Duh1mux_SO9e9idddbgeIJZpp1brgaBmgJWgKFHk, 97, 45, 115, 116, 114, 105, 110, 103, 45, 115, 101, 99, 1… → valid false, claims —, error token_not_yet_valid, message the token is not valid yet
Show the other 12 tests
CaseArgumentsExpected
nbf in the future but within the leeway eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYmYiOjE3OTA0MjU4MjAsInN1YiI6IjQyIn0.0SnbadIZHfgVQvAskhVMs5VYJxgCIVMvRvGjZWn6R6U, 97, 45, 115, 116, 114, 105, 110, 103, 45, 115, 101, 99, 1… → valid true, claims …, error —, message —
iat in the future beyond the leeway eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpYXQiOjE3OTA0MjYwMDAsInN1YiI6IjQyIn0.qGztfRKO451NyNWZ2MjSGgKVkbB5vPDURwY_J4rbxaw, 97, 45, 115, 116, 114, 105, 110, 103, 45, 115, 101, 99, 1… → valid false, claims —, error token_issued_in_future, message the token was issued in the future
exp as a string is not a NumericDate eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOiIxNzkwNDI3NjAwIiwic3ViIjoiNDIifQ.OYcP5eZGvaHjXOXC9u_luKmy2P0Ga8Qwf3DpVbu4YQ0, 97, 45, 115, 116, 114, 105, 110, 103, 45, 115, 101, 99… → valid false, claims —, error invalid_claims, message the token's exp claim is not a number
only two parts eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3OTA0Mjc2MDAsImlhdCI6MTc5MDQyNDAwMCwianRpIjoiYzNWd1pYSXRkVzVwY1hWbCIsIm5hbWUiOiJBZGEgTG92ZWxhY2UiLCJzdWIiOiI0MiIsInZlciI6MX0, 97, 4… → valid false, claims —, error malformed_token, message the token is not a well-formed JWT
the empty string , 97, 45, 115, 116, 114, 105, 110, 103, 45, 115, 101, 99, 114, 101, 116, 45, 97, 116, 45, 108, 101, 97, 115, 116, 45, 50, 53, 54, 45, 98, 105, 116, 115, 45, 108, 111, 110, 103, 1,… → valid false, claims —, error malformed_token, message the token is not a well-formed JWT
a payload that is JSON but not an object eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.WzEsMiwzXQ.awvlufPKfBwu7aTZYEZMDJ6ryhHw48sgLbPzQcBTFwU, 97, 45, 115, 116, 114, 105, 110, 103, 45, 115, 101, 99, 114, 101, 116, 45, 97, 116, 45… → valid false, claims —, error malformed_token, message the token is not a well-formed JWT
base64 padding in a segment is not the compact form eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3OTA0Mjc2MDAsImlhdCI6MTc5MDQyNDAwMCwianRpIjoiYzNWd1pYSXRkVzVwY1hWbCIsIm5hbWUiOiJBZGEgTG92ZWxhY2UiLCJzdWIiOiI0MiIsInZlciI6MX0.Ohi2Vy… → valid false, claims —, error malformed_token, message the token is not a well-formed JWT
a payload that is not UTF-8 eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhIjoi_yJ9.Ohi2VySpSMFwlM-7lTI51a2O-0PfhwjlVb5jmoS5yeU, 97, 45, 115, 116, 114, 105, 110, 103, 45, 115, 101, 99, 114, 101, 116, 45, 97, 116, … → valid false, claims —, error malformed_token, message the token is not a well-formed JWT
NaN in the payload, which Python's parser would otherwise accept eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOk5hTn0.Z3VEeJ4ChV9SwwVk9QRNNuHKucV7V2MeeGcLjD7QlPw, 97, 45, 115, 116, 114, 105, 110, 103, 45, 115, 101, 99, 114, 101, 116, 45, 97, 11… → valid false, claims —, error malformed_token, message the token is not a well-formed JWT
a secret shorter than 32 bytes is the caller's error, not a token answer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3OTA0Mjc2MDAsImlhdCI6MTc5MDQyNDAwMCwianRpIjoiYzNWd1pYSXRkVzVwY1hWbCIsIm5hbWUiOiJBZGEgTG92ZWxhY2UiLCJzdWIiOiI0MiIsInZlciI6MX0.Ohi2Vy… → error: secret must be at least 32 bytes (RFC 7518 section 3.2), received 5
negative leeway eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3OTA0Mjc2MDAsImlhdCI6MTc5MDQyNDAwMCwianRpIjoiYzNWd1pYSXRkVzVwY1hWbCIsIm5hbWUiOiJBZGEgTG92ZWxhY2UiLCJzdWIiOiI0MiIsInZlciI6MX0.Ohi2Vy… → error: leewaySeconds must be a whole number, 0 or more
now given in fractional seconds eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3OTA0Mjc2MDAsImlhdCI6MTc5MDQyNDAwMCwianRpIjoiYzNWd1pYSXRkVzVwY1hWbCIsIm5hbWUiOiJBZGEgTG92ZWxhY2UiLCJzdWIiOiI0MiIsInZlciI6MX0.Ohi2Vy… → error: now must be a whole number of Unix seconds

decodeJwt 18 tests

CaseArgumentsExpected
RFC 7515 appendix A.1: the example token's claims eyJ0eXAiOiJKV1QiLA0KICJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJqb2UiLA0KICJleHAiOjEzMDA4MTkzODAsDQogImh0dHA6Ly9leGFtcGxlLmNvbS9pc19yb290Ijp0cnVlfQ.dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk → iss joe, exp 1,300,819,380, http://example.com/is_root true
a token signJwt made eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3OTA0Mjc2MDAsImlhdCI6MTc5MDQyNDAwMCwianRpIjoiYzNWd1pYSXRkVzVwY1hWbCIsIm5hbWUiOiJBZGEgTG92ZWxhY2UiLCJzdWIiOiI0MiIsInZlciI6MX0.Ohi2Vy… → sub 42, name Ada Lovelace, iat 1,790,424,000, exp 1,790,427,600, jti c3VwZXItdW5pcXVl, ver 1
the signature is not checked: tampered claims still decode eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3OTA0Mjc2MDAsImlhdCI6MTc5MDQyNDAwMCwianRpIjoiYzNWd1pYSXRkVzVwY1hWbCIsIm5hbWUiOiJBZGEgTG92ZWxhY2UiLCJzdWIiOiIxIiwidmVyIjoxfQ.Ohi2VyS… → sub 1, name Ada Lovelace, iat 1,790,424,000, exp 1,790,427,600, jti c3VwZXItdW5pcXVl, ver 1
an alg none token decodes (and verifyJwt would refuse it) eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJleHAiOjE3OTA0Mjc2MDAsImlhdCI6MTc5MDQyNDAwMCwianRpIjoiYzNWd1pYSXRkVzVwY1hWbCIsIm5hbWUiOiJBZGEgTG92ZWxhY2UiLCJzdWIiOiI0MiIsInZlciI6MX0. → sub 42, name Ada Lovelace, iat 1,790,424,000, exp 1,790,427,600, jti c3VwZXItdW5pcXVl, ver 1
an expired token still decodes; reading exp is the point eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjF9.kM3H33Lrr02vuN2nyW3tLvAeOhxBDnjRcdmnSsE3stM → exp 1
not a JWT at all hello → —
the empty string → —
four parts eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3OTA0Mjc2MDAsImlhdCI6MTc5MDQyNDAwMCwianRpIjoiYzNWd1pYSXRkVzVwY1hWbCIsIm5hbWUiOiJBZGEgTG92ZWxhY2UiLCJzdWIiOiI0MiIsInZlciI6MX0.Ohi2Vy… → —
a header that is not JSON bm90IGpzb24.eyJleHAiOjE3OTA0Mjc2MDAsImlhdCI6MTc5MDQyNDAwMCwianRpIjoiYzNWd1pYSXRkVzVwY1hWbCIsIm5hbWUiOiJBZGEgTG92ZWxhY2UiLCJzdWIiOiI0MiIsInZlciI6MX0.Ohi2VySpSMFwlM-7lTI51a2O-0Pfhwj… → —
a payload that is a JSON string, not an object eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.ImhlbGxvIg.xlSnpdTxzJP-c6aHtil6OidO18-O1_dnQGbk9JlHDUw → —
Show the other 8 tests
CaseArgumentsExpected
an integer beyond 2^53 is refused, since JavaScript would round it eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6OTAwNzE5OTI1NDc0MDk5M30.wnSgywnzmbeCIbzKicr_mYOSWTR6c1tibW2tPZU1SLc → —
an escaped lone surrogate is refused eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjoiXHVkODAwIn0.NbmV3UAJ6BUq9zpwOTy6lPV1YFGXZhgkSGggtfVQHxE → —
an escaped surrogate pair is one character eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjoiXHVkODNkXHVkZTAwIn0.lbtCXl2nVVxdtXDvCaPTzf2kGfd_x0_dIJLFBGXEh60 → name 😀
the canonical form of the same small token decodes eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhIjoxfQ.pHz1uz9qdaLadovi6pLeKPvCegaT6WHF2YnrA-eT7qw → a 1
non-canonical base64url: the payload's last character carries non-zero spare bits (a lenient decoder reads the same claims) eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhIjoxfR.pHz1uz9qdaLadovi6pLeKPvCegaT6WHF2YnrA-eT7qw → —
32 levels of nesting are accepted eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6ey… → a …
33 levels of nesting are refused eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6ey… → —
duplicate keys: the last value wins, as in JavaScript and Python eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhIjoxLCJhIjoyfQ.N4pPAo5By0BC3mh_2DkTecjzkZxl4nZ9cuzyxtuJqwI → a 2

More from the author

The secret is bytes (a list of integers 0 to 255, as everywhere in the registry; `utf8Encode` a text secret) and must be at least 32 bytes: RFC 7518 section 3.2 requires an HS256 key at least as long as the hash. A shorter one is refused by both `signJwt` and `verifyJwt`, loudly, since it is a configuration mistake rather than a bad token.

**verifyJwt never throws for a bad token.** It answers `{valid: false, error, message}` with one of these codes, checked in this order:

| error | when | |---|---| | `malformed_token` | not three canonical base64url parts, a header or payload that is not a JSON object, not UTF-8, or JSON the three languages could read differently (below) | | `unsupported_algorithm` | the header's `alg` is not exactly `HS256` (`none`, `RS256`, missing), or it has a `crit` header (RFC 7515 section 4.1.11 requires refusing extensions you do not understand) | | `invalid_signature` | the HMAC-SHA256 of `header.payload` does not match, compared in constant time | | `invalid_claims` | `exp`, `nbf` or `iat` is present but not a number | | `token_expired` | `now >= exp + leeway` (RFC 7519 section 4.1.4: now must be *before* exp) | | `token_not_yet_valid` | `now + leeway < nbf` (section 4.1.5) | | `token_issued_in_future` | `iat > now + leeway` |

The algorithm is checked before any key is used, so the classic attacks (`alg: none`, or an RS256 public key used as an HMAC secret) cannot reach the signature check. `now` is Unix seconds read by the caller; the capability never reads the clock. `exp`, `nbf` and `iat` are each optional here: a token without `exp` never expires, so a caller that requires one should check the claims (`auth.access-token` does). `iss`, `aud`, `sub` and `jti` are returned, not judged.

**signJwt** writes the header `{"alg":"HS256","typ":"JWT"}` and the claims as one canonical JSON text: no whitespace, object keys sorted by code point at every level, non-ASCII written as UTF-8 rather than `\u` escapes, and the usual JSON escapes for quotes, backslashes and control characters. Equal claims therefore sign to byte-identical tokens in every language. Numbers must be whole and within ±(2^53 − 1), the integers JavaScript holds exactly; NumericDates (`exp`, `iat`) are whole seconds anyway. It does not add any claim itself.

**decodeJwt** is for a client reading its own token (to show a name, or to refresh before `exp`). It checks the shape only; anyone can make a token that decodes. Never use it to decide access.

**One reading of JSON.** A token is attacker-controlled input, and three JSON parsers disagree at the edges, so each language refuses the same things: integers beyond 2^53, numbers that overflow to infinity, `NaN`, an escaped lone surrogate (`"\ud800"`), nesting deeper than 32, and anything that is not strict RFC 8259 JSON. A repeated key keeps its last value, as JavaScript and Python both do. Base64url segments must be canonical and unpadded (RFC 7515 section 2).

The RFC 7515 appendix A.1 token (also RFC 7519 section 3.1) is among the vectors, verified and decoded with the RFC's key. Its header has a line break and `typ` first, which is why `signJwt` of the same claims gives a different, canonical token: a JWT is verified as sent, never re-serialised.

Sources: RFC 7519, JSON Web Token (https://www.rfc-editor.org/rfc/rfc7519); RFC 7515, JSON Web Signature, section 7.1 and appendix A.1 (https://www.rfc-editor.org/rfc/rfc7515); RFC 7518, JSON Web Algorithms, section 3.2 (https://www.rfc-editor.org/rfc/rfc7518#section-3.2); RFC 8725, JSON Web Token Best Current Practices, sections 2.1 and 3.1 (https://www.rfc-editor.org/rfc/rfc8725).

Files

PathBytes
README.md4,170
impl/python/decode_jwt.py4,227
impl/python/sign_jwt.py2,396
impl/python/verify_jwt.py2,679
impl/rust/decode_jwt.rs10,817
impl/rust/sign_jwt.rs4,201
impl/rust/verify_jwt.rs4,554
impl/typescript/decode_jwt.ts4,863
impl/typescript/sign_jwt.ts3,412
impl/typescript/verify_jwt.ts2,879
vectors.json23,930