Functional Weave
Code in Python

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. sign_jwt (claims: record, secret: int[]) -> string
  2. verify_jwt (token: string, secret: int[], now: int, leewaySeconds: int) -> JwtVerification
  3. decode_jwt (token: string) -> record?

The types it declares, generated into your project

@dataclass(frozen=True)
class JwtVerification:
    """Whether a token may be trusted and, when it may, what it says."""

    valid: bool
    #: the payload, when valid
    claims: Optional[Mapping[str, Any]]
    #: why not, when not valid
    error: Optional[JwtError]
    #: the reason in words, for logs rather than end users
    message: Optional[str]

JwtError = Literal["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.

sign_jwt throws on bad input 14 tests

def sign_jwt(claims: Mapping[str, Any], secret: Sequence[int]) -> str
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

  • sign_jwt(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
  • sign_jwt(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
  • sign_jwt(, 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
from fune.auth.jwt import sign_jwt  # auth.jwt@^1
impl/python/sign_jwt.py · 51 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 json
from typing import Any, Mapping, Sequence

from .auth_jwt_decode_jwt import check_jwt_secret  ← decodeJwt, another function of this group · built into the same file, even by a slim install
from .crypto_hmac_sha256 import hmac_sha256  ← from crypto.hmac-sha256 ^1.0.0 · built alongside by fune
from .encoding_base64_base64_url_encode import base64_url_encode
from .encoding_utf8_utf8_encode import utf8_encode

#: base64url of {"alg":"HS256","typ":"JWT"}: the header is fixed.
HEADER = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9"
MAX_DEPTH = 32
MAX_SAFE = 9007199254740991


def _canonical(value: Any, depth: int) -> Any:
    """The value with every number made a whole int, checked the way the other
    languages check it; json.dumps then writes it with sorted keys."""
    if value is None or isinstance(value, (bool, str)):
        return value
    if isinstance(value, (int, float)):
        if isinstance(value, float) and not value.is_integer():
            raise ValueError("claims may only hold whole numbers from -(2^53 - 1) to 2^53 - 1")
        if abs(value) > MAX_SAFE:
            raise ValueError("claims may only hold whole numbers from -(2^53 - 1) to 2^53 - 1")
        return int(value)
    if isinstance(value, (list, tuple)):
        if depth > MAX_DEPTH:
            raise ValueError("claims are nested more than 32 deep")
        return [_canonical(item, depth + 1) for item in value]
    if isinstance(value, Mapping):
        if depth > MAX_DEPTH:
            raise ValueError("claims are nested more than 32 deep")
        out = {}
        for key, item in value.items():
            if not isinstance(key, str):
                raise TypeError("claims must hold only JSON values")
            out[key] = _canonical(item, depth + 1)
        return out
    raise TypeError("claims must hold only JSON values")


def sign_jwt(claims: Mapping[str, Any], secret: Sequence[int]) -> str:
    """An HS256 JWT for the claims: header {"alg":"HS256","typ":"JWT"}, the
    claims as canonical JSON (no spaces, keys sorted by code point, UTF-8
    rather than \\u escapes), and the HMAC-SHA256 of the two."""
    check_jwt_secret(secret)
    if not isinstance(claims, Mapping):
        raise TypeError("claims must be a JSON object")
    payload = json.dumps(_canonical(claims, 1), sort_keys=True, separators=(",", ":"), ensure_ascii=False, allow_nan=False)
    signing_input = HEADER + "." + base64_url_encode(utf8_encode(payload))
    return signing_input + "." + base64_url_encode(hmac_sha256(secret, utf8_encode(signing_input)))

verify_jwt throws on bad input 22 tests

def verify_jwt(token: str, secret: Sequence[int], now: int, leeway_seconds: int) -> 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
leeway_secondsintclock 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

  • verify_jwt(eyJ0eXAiOiJKV1QiLA0KICJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJqb2UiLA0KICJleHAiOjEzMDA4MTkzODAsDQogImh0dHA6Ly9leGFtcGxlLmNvbS9pc19yb290Ijp0cnVlfQ.dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk…) → valid true, claims …, error —, message — RFC 7515 appendix A.1: the example token, one second before exp
  • verify_jwt(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)
  • verify_jwt(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
from fune.auth.jwt import verify_jwt  # auth.jwt@^1
impl/python/verify_jwt.py · 49 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.

from typing import Sequence

from .auth_jwt_decode_jwt import check_jwt_secret, split_jwt  ← decodeJwt, another function of this group · built into the same file, even by a slim install
from .auth_jwt_types import JwtVerification
from .crypto_constant_time_equal import constant_time_equal  ← from crypto.constant-time-equal ^1.0.0 · built alongside by fune
from .crypto_hmac_sha256 import hmac_sha256  ← from crypto.hmac-sha256 ^1.0.0 · built alongside by fune
from .encoding_utf8_utf8_encode import utf8_encode


def _fail(error: str, message: str) -> JwtVerification:
    return JwtVerification(valid=False, claims=None, error=error, message=message)


def verify_jwt(token: str, secret: Sequence[int], now: int, leeway_seconds: int) -> JwtVerification:
    """Check an HS256 token: shape, algorithm (so "none" and algorithm-confusion
    tokens are refused before any key is used), 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 raises."""
    check_jwt_secret(secret)
    if isinstance(now, bool) or not isinstance(now, int):
        raise TypeError("now must be a whole number of Unix seconds")
    if isinstance(leeway_seconds, bool) or not isinstance(leeway_seconds, int) or leeway_seconds < 0:
        raise ValueError("leewaySeconds must be a whole number, 0 or more")

    parts = split_jwt(token)
    if parts is None:
        return _fail("malformed_token", "the token is not a well-formed JWT")
    header, claims, signing_input, signature = parts
    if header.get("alg") != "HS256":
        return _fail("unsupported_algorithm", "only HS256 tokens are accepted")
    if "crit" in header:
        return _fail("unsupported_algorithm", "the token requires header extensions (crit) this verifier does not support")

    expected = hmac_sha256(secret, utf8_encode(signing_input))
    if not constant_time_equal(expected, signature):
        return _fail("invalid_signature", "the token's signature does not match")

    for name in ("exp", "nbf", "iat"):
        if name in claims and (isinstance(claims[name], bool) or not isinstance(claims[name], (int, float))):
            return _fail("invalid_claims", "the token's %s claim is not a number" % name)
    # RFC 7519 4.1.4: the current time MUST be before exp.
    if "exp" in claims and now >= claims["exp"] + leeway_seconds:
        return _fail("token_expired", "the token has expired")
    # RFC 7519 4.1.5: the current time MUST be at or after nbf.
    if "nbf" in claims and now + leeway_seconds < claims["nbf"]:
        return _fail("token_not_yet_valid", "the token is not valid yet")
    if "iat" in claims and claims["iat"] > now + leeway_seconds:
        return _fail("token_issued_in_future", "the token was issued in the future")
    return JwtVerification(valid=True, claims=claims, error=None, message=None)

decode_jwt 18 tests

def decode_jwt(token: str) -> Optional[Mapping[str, Any]]
tokenstringa compact JWT
returnsrecord?its claims WITHOUT checking the signature, for a browser to read exp or name; null if malformed

For example

  • decode_jwt(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
  • decode_jwt(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
  • decode_jwt(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
from fune.auth.jwt import decode_jwt  # auth.jwt@^1
impl/python/decode_jwt.py · 118 lines · open · raw
import json
import math
from typing import Any, Dict, List, Mapping, Optional, Sequence, Tuple

from .encoding_base64_base64_url_decode import base64_url_decode

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


def _sextet(ch: str) -> int:
    if "A" <= ch <= "Z":
        return ord(ch) - 65
    if "a" <= ch <= "z":
        return ord(ch) - 71
    if "0" <= ch <= "9":
        return ord(ch) + 4
    if ch == "-":
        return 62
    if ch == "_":
        return 63
    return -1


def _is_segment(text: str, allow_empty: bool) -> bool:
    """One canonical, unpadded base64url segment? Checked before decoding so a
    hostile token is an answer (None), never an exception."""
    if len(text) == 0:
        return allow_empty
    if len(text) % 4 == 1:
        return False
    last = 0
    for ch in text:
        last = _sextet(ch)
        if last < 0:
            return False
    if len(text) % 4 == 2:
        return last & 15 == 0
    if len(text) % 4 == 3:
        return last & 3 == 0
    return True


def _reject_constant(name: str) -> Any:
    # json.loads accepts NaN and Infinity, which are not JSON.
    raise ValueError("not JSON: %s" % name)


def _has_surrogate(text: str) -> bool:
    return any(0xD800 <= ord(ch) <= 0xDFFF for ch in text)


def _is_portable(value: Any, depth: int) -> bool:
    """Refuse what the three languages' parsers would answer differently:
    integers beyond 2^53, non-finite numbers, lone surrogates, deep nesting."""
    if isinstance(value, bool) or value is None:
        return True
    if isinstance(value, int):
        return abs(value) <= MAX_SAFE
    if isinstance(value, float):
        return math.isfinite(value) and (not value.is_integer() or abs(value) <= MAX_SAFE)
    if isinstance(value, str):
        return not _has_surrogate(value)
    if depth > MAX_DEPTH:
        return False
    if isinstance(value, list):
        return all(_is_portable(item, depth + 1) for item in value)
    return all(not _has_surrogate(k) and _is_portable(v, depth + 1) for k, v in value.items())


def _parse_object(segment: str) -> Optional[Dict[str, Any]]:
    try:
        text = bytes(base64_url_decode(segment)).decode("utf-8")
        parsed = json.loads(text, parse_constant=_reject_constant)
    except (ValueError, RecursionError):
        return None
    if not isinstance(parsed, dict):
        return None
    return parsed if _is_portable(parsed, 1) else None


def split_jwt(token: str) -> Optional[Tuple[Dict[str, Any], Dict[str, Any], str, List[int]]]:
    """(header, claims, signing input, signature bytes) of a compact JWT, or
    None when it is not three canonical base64url segments whose first two
    are JSON objects. Nothing is verified here; verify_jwt builds on it."""
    if not isinstance(token, str):
        return None
    parts = token.split(".")
    if len(parts) != 3:
        return None
    if not _is_segment(parts[0], False) or not _is_segment(parts[1], False) or not _is_segment(parts[2], True):
        return None
    header = _parse_object(parts[0])
    claims = _parse_object(parts[1])
    if header is None or claims is None:
        return None
    return header, claims, parts[0] + "." + parts[1], base64_url_decode(parts[2])


def check_jwt_secret(secret: Sequence[int]) -> None:
    """RFC 7518 section 3.2: an HS256 key MUST be at least 256 bits."""
    if isinstance(secret, (str, dict)) or not hasattr(secret, "__len__"):
        raise TypeError("secret must be a list of integers from 0 to 255")
    for b in secret:
        if type(b) is not int or b < 0 or b > 255:
            raise ValueError("secret must be a list of integers from 0 to 255")
    if len(secret) < 32:
        raise ValueError("secret must be at least 32 bytes (RFC 7518 section 3.2), received %d" % len(secret))


def decode_jwt(token: str) -> Optional[Mapping[str, Any]]:
    """The claims of a JWT WITHOUT checking its signature, or None when it is
    malformed. For reading exp or name from a token you hold; never for
    deciding whether to trust one."""
    parts = split_jwt(token)
    return None if parts is None else parts[1]

Install

fune build

With that line in your source, in a Python project (language python in fune.project), fune build resolves it and its 4 dependencies, pins them in fune.lock, downloads only the Python 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 Python auth.jwt-1.0.0-python.fune · 44,350 bytes sha256 dcf6f8f4df0910b23d3803d6491683feec75377d7cea7ae5cebb94fd818d9ba2

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

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