Functional Weave
Code in Rust

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

/// Whether a token may be trusted and, when it may, what it says.
#[derive(Debug, Clone, PartialEq)]
pub struct JwtVerification {
    pub valid: bool,
    /// the payload, when valid
    pub claims: Option<Value>,
    /// why not, when not valid
    pub error: Option<String>,
    /// the reason in words, for logs rather than end users
    pub message: Option<String>,
}

// JwtError is a string in Rust, one of: "malformed_token", "unsupported_algorithm", "invalid_signature", "invalid_claims", "token_expired", "token_not_yet_valid", "token_issued_in_future".
// Parameters take it as &str and results hold it as String.

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

sign_jwt throws on bad input 14 tests

pub fn sign_jwt(claims: &Value, secret: &[i64]) -> 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

  • 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
fune!(auth.jwt@^1);  // then call sign_jwt(…)
impl/rust/sign_jwt.rs · 113 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.

use super::funejson::Value;  ← the fune runtime: the JSON value the test vectors use; fune build keeps it only where a signature takes one
use super::auth_jwt_decode_jwt::check_jwt_secret;  ← decodeJwt, another function of this group · built into the same file, even by a slim install
use super::crypto_hmac_sha256::hmac_sha256;  ← from crypto.hmac-sha256 ^1.0.0 · built alongside by fune
use super::encoding_base64_base64_url_encode::base64_url_encode;

/// base64url of {"alg":"HS256","typ":"JWT"}: the header is fixed.
const HEADER: &str = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9";
const MAX_DEPTH: usize = 32;
const MAX_SAFE: i64 = 9007199254740991;

fn quote(text: &str, out: &mut String) {
    out.push('"');
    for ch in text.chars() {
        match ch {
            '"' => out.push_str("\\\""),
            '\\' => out.push_str("\\\\"),
            '\n' => out.push_str("\\n"),
            '\r' => out.push_str("\\r"),
            '\t' => out.push_str("\\t"),
            '\u{8}' => out.push_str("\\b"),
            '\u{c}' => out.push_str("\\f"),
            c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
            c => out.push(c),
        }
    }
    out.push('"');
}

/// One canonical JSON text: no spaces, object keys sorted by code point
/// (Rust's `String` order), whole numbers only, non-ASCII written as UTF-8.
fn canonical(value: &Value, depth: usize, out: &mut String) {
    const WHOLE: &str = "claims may only hold whole numbers from -(2^53 - 1) to 2^53 - 1";
    match value {
        Value::Null => out.push_str("null"),
        Value::Bool(b) => out.push_str(if *b { "true" } else { "false" }),
        Value::Int(i) => {
            if i.abs() > MAX_SAFE {
                panic!("{}", WHOLE);
            }
            out.push_str(&i.to_string());
        }
        Value::Float(f) => {
            if !f.is_finite() || f.fract() != 0.0 || f.abs() > MAX_SAFE as f64 {
                panic!("{}", WHOLE);
            }
            out.push_str(&(*f as i64).to_string());
        }
        Value::Str(s) => quote(s, out),
        Value::Arr(items) => {
            if depth > MAX_DEPTH {
                panic!("claims are nested more than 32 deep");
            }
            out.push('[');
            for (i, item) in items.iter().enumerate() {
                if i > 0 {
                    out.push(',');
                }
                canonical(item, depth + 1, out);
            }
            out.push(']');
        }
        Value::Obj(pairs) => {
            if depth > MAX_DEPTH {
                panic!("claims are nested more than 32 deep");
            }
            let mut sorted: Vec<&(String, Value)> = pairs.iter().collect();
            sorted.sort_by(|a, b| a.0.cmp(&b.0));
            out.push('{');
            for (i, (key, item)) in sorted.into_iter().enumerate() {
                if i > 0 {
                    out.push(',');
                }
                quote(key, out);
                out.push(':');
                canonical(item, depth + 1, out);
            }
            out.push('}');
        }
    }
}

/// 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.
///
/// # Panics
/// Panics if the secret is under 32 bytes, the claims are not an object, or
/// they hold a fraction, an unsafe integer or nesting past 32.
pub fn sign_jwt(claims: &Value, secret: &[i64]) -> String {
    check_jwt_secret(secret);
    if !matches!(claims, Value::Obj(_)) {
        panic!("claims must be a JSON object");
    }
    let mut payload = String::new();
    canonical(claims, 1, &mut payload);
    let payload_bytes: Vec<i64> = payload.bytes().map(|b| b as i64).collect();
    let signing_input = format!("{}.{}", HEADER, base64_url_encode(&payload_bytes));
    let input: Vec<i64> = signing_input.bytes().map(|b| b as i64).collect();
    format!("{}.{}", signing_input, base64_url_encode(&hmac_sha256(secret, &input)))
}

pub fn fune_vector(args: &[Value]) -> Value {
    let secret: Vec<i64> = match &args[1] {
        Value::Arr(items) => items
            .iter()
            .map(|item| match item {
                Value::Int(i) => *i,
                _ => panic!("secret must be a list of integers from 0 to 255"),
            })
            .collect(),
        _ => panic!("secret must be a list of integers from 0 to 255"),
    };
    Value::str(&sign_jwt(&args[0], &secret))
}

verify_jwt throws on bad input 22 tests

pub fn verify_jwt(token: &str, secret: &[i64], now: i64, leeway_seconds: i64) -> 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
fune!(auth.jwt@^1);  // then call verify_jwt(…)
impl/rust/verify_jwt.rs · 121 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.

use super::funejson::Value;  ← the fune runtime: the JSON value the test vectors use; fune build keeps it only where a signature takes one
use super::auth_jwt_decode_jwt::{check_jwt_secret, split_jwt};  ← decodeJwt, another function of this group · built into the same file, even by a slim install
use super::crypto_constant_time_equal::constant_time_equal;  ← from crypto.constant-time-equal ^1.0.0 · built alongside by fune
use super::crypto_hmac_sha256::hmac_sha256;  ← from crypto.hmac-sha256 ^1.0.0 · built alongside by fune

fn fail(error: &str, message: &str) -> JwtVerification {
    JwtVerification {
        valid: false,
        claims: None,
        error: Some(error.to_string()),
        message: Some(message.to_string()),
    }
}

/// A present claim, as distinct from an absent one: `Value::get` answers
/// Null for both.
fn field<'v>(object: &'v Value, key: &str) -> Option<&'v Value> {
    match object {
        Value::Obj(pairs) => pairs.iter().find(|(k, _)| k == key).map(|(_, v)| v),
        _ => None,
    }
}

/// 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 a panic; only a bad secret or leeway panics.
///
/// # Panics
/// Panics if the secret is not at least 32 bytes or the leeway is negative.
pub fn verify_jwt(token: &str, secret: &[i64], now: i64, leeway_seconds: i64) -> JwtVerification {
    check_jwt_secret(secret);
    if leeway_seconds < 0 {
        panic!("leewaySeconds must be a whole number, 0 or more");
    }
    let (header, claims, signing_input, signature) = match split_jwt(token) {
        Some(parts) => parts,
        None => return fail("malformed_token", "the token is not a well-formed JWT"),
    };
    if field(&header, "alg") != Some(&Value::str("HS256")) {
        return fail("unsupported_algorithm", "only HS256 tokens are accepted");
    }
    if field(&header, "crit").is_some() {
        return fail(
            "unsupported_algorithm",
            "the token requires header extensions (crit) this verifier does not support",
        );
    }
    let input: Vec<i64> = signing_input.bytes().map(|b| b as i64).collect();
    if !constant_time_equal(&hmac_sha256(secret, &input), &signature) {
        return fail("invalid_signature", "the token's signature does not match");
    }

    let mut times: [Option<f64>; 3] = [None, None, None];
    for (slot, name) in ["exp", "nbf", "iat"].iter().enumerate() {
        match field(&claims, name) {
            None => {}
            Some(Value::Int(i)) => times[slot] = Some(*i as f64),
            Some(Value::Float(f)) => times[slot] = Some(*f),
            Some(_) => {
                return fail("invalid_claims", &format!("the token's {} claim is not a number", name));
            }
        }
    }
    let now = now as f64;
    let leeway = leeway_seconds as f64;
    // RFC 7519 4.1.4: the current time MUST be before exp.
    if let Some(exp) = times[0] {
        if now >= exp + leeway {
            return fail("token_expired", "the token has expired");
        }
    }
    // RFC 7519 4.1.5: the current time MUST be at or after nbf.
    if let Some(nbf) = times[1] {
        if now + leeway < nbf {
            return fail("token_not_yet_valid", "the token is not valid yet");
        }
    }
    if let Some(iat) = times[2] {
        if iat > now + leeway {
            return fail("token_issued_in_future", "the token was issued in the future");
        }
    }
    JwtVerification { valid: true, claims: Some(claims), error: None, message: None }
}

pub fn jwt_verification_to_value(result: &JwtVerification) -> Value {
    let text = |v: &Option<String>| match v {
        Some(s) => Value::str(s),
        None => Value::Null,
    };
    Value::obj(vec![
        ("valid", Value::Bool(result.valid)),
        ("claims", result.claims.clone().unwrap_or(Value::Null)),
        ("error", text(&result.error)),
        ("message", text(&result.message)),
    ])
}

fn whole(value: &Value, message: &str) -> i64 {
    match value {
        Value::Int(i) => *i,
        _ => panic!("{}", message),
    }
}

pub fn fune_vector(args: &[Value]) -> Value {
    let secret: Vec<i64> = match &args[1] {
        Value::Arr(items) => items
            .iter()
            .map(|item| match item {
                Value::Int(i) => *i,
                _ => panic!("secret must be a list of integers from 0 to 255"),
            })
            .collect(),
        _ => panic!("secret must be a list of integers from 0 to 255"),
    };
    let now = whole(&args[2], "now must be a whole number of Unix seconds");
    let leeway = whole(&args[3], "leewaySeconds must be a whole number, 0 or more");
    jwt_verification_to_value(&verify_jwt(args[0].as_str(), &secret, now, leeway))
}

decode_jwt 18 tests

pub fn decode_jwt(token: &str) -> Option<Value>
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
fune!(auth.jwt@^1);  // then call decode_jwt(…)
impl/rust/decode_jwt.rs · 322 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.

use super::funejson::Value;  ← the fune runtime: the JSON value the test vectors use; fune build keeps it only where a signature takes one
use super::encoding_base64_base64_url_decode::base64_url_decode;

/// Containers nested deeper than this are refused, in every language, rather
/// than left to each parser's own limit.
const MAX_DEPTH: usize = 32;
const MAX_SAFE_F64: f64 = 9007199254740991.0;

fn sextet(b: u8) -> i64 {
    match b {
        b'A'..=b'Z' => (b - b'A') as i64,
        b'a'..=b'z' => (b - b'a' + 26) as i64,
        b'0'..=b'9' => (b - b'0' + 52) as i64,
        b'-' => 62,
        b'_' => 63,
        _ => -1,
    }
}

/// One canonical, unpadded base64url segment? Checked before decoding so a
/// hostile token is an answer (`None`), never a panic.
fn is_segment(text: &str, allow_empty: bool) -> bool {
    let b = text.as_bytes();
    if b.is_empty() {
        return allow_empty;
    }
    if b.len() % 4 == 1 {
        return false;
    }
    let mut last = 0;
    for &c in b {
        last = sextet(c);
        if last < 0 {
            return false;
        }
    }
    match b.len() % 4 {
        2 => last & 15 == 0,
        3 => last & 3 == 0,
        _ => true,
    }
}

/// A strict RFC 8259 parser. The funejson runtime's parser is built for test
/// vectors and is lenient (a leading +, any Unicode whitespace); a token from
/// the network has to be read exactly as TypeScript's JSON.parse and
/// Python's json.loads read it, with the same refusals: integers beyond
/// 2^53, non-finite numbers, lone surrogates and nesting past 32.
struct Parser<'a> {
    b: &'a [u8],
    pos: usize,
}

impl<'a> Parser<'a> {
    fn ws(&mut self) {
        while self.pos < self.b.len() && matches!(self.b[self.pos], b' ' | b'\t' | b'\n' | b'\r') {
            self.pos += 1;
        }
    }

    fn peek(&self) -> Option<u8> {
        self.b.get(self.pos).copied()
    }

    fn literal(&mut self, word: &[u8], value: Value) -> Option<Value> {
        if self.b.len() >= self.pos + word.len() && &self.b[self.pos..self.pos + word.len()] == word {
            self.pos += word.len();
            Some(value)
        } else {
            None
        }
    }

    fn value(&mut self, depth: usize) -> Option<Value> {
        self.ws();
        match self.peek()? {
            b'{' => {
                if depth > MAX_DEPTH {
                    return None;
                }
                self.pos += 1;
                let mut pairs: Vec<(String, Value)> = Vec::new();
                self.ws();
                if self.peek() == Some(b'}') {
                    self.pos += 1;
                    return Some(Value::Obj(pairs));
                }
                loop {
                    self.ws();
                    if self.peek()? != b'"' {
                        return None;
                    }
                    let key = self.string()?;
                    self.ws();
                    if self.peek()? != b':' {
                        return None;
                    }
                    self.pos += 1;
                    let item = self.value(depth + 1)?;
                    // A repeated key keeps its first position and its last
                    // value, as JavaScript objects and Python dicts do.
                    match pairs.iter_mut().find(|(k, _)| *k == key) {
                        Some(slot) => slot.1 = item,
                        None => pairs.push((key, item)),
                    }
                    self.ws();
                    match self.peek()? {
                        b',' => self.pos += 1,
                        b'}' => {
                            self.pos += 1;
                            return Some(Value::Obj(pairs));
                        }
                        _ => return None,
                    }
                }
            }
            b'[' => {
                if depth > MAX_DEPTH {
                    return None;
                }
                self.pos += 1;
                let mut items = Vec::new();
                self.ws();
                if self.peek() == Some(b']') {
                    self.pos += 1;
                    return Some(Value::Arr(items));
                }
                loop {
                    items.push(self.value(depth + 1)?);
                    self.ws();
                    match self.peek()? {
                        b',' => self.pos += 1,
                        b']' => {
                            self.pos += 1;
                            return Some(Value::Arr(items));
                        }
                        _ => return None,
                    }
                }
            }
            b'"' => self.string().map(Value::Str),
            b't' => self.literal(b"true", Value::Bool(true)),
            b'f' => self.literal(b"false", Value::Bool(false)),
            b'n' => self.literal(b"null", Value::Null),
            _ => self.number(),
        }
    }

    fn hex4(&mut self) -> Option<u32> {
        if self.pos + 4 > self.b.len() {
            return None;
        }
        let mut n = 0u32;
        for &c in &self.b[self.pos..self.pos + 4] {
            n = n * 16 + (c as char).to_digit(16)?;
        }
        self.pos += 4;
        Some(n)
    }

    fn string(&mut self) -> Option<String> {
        self.pos += 1; // opening quote
        let mut out: Vec<u8> = Vec::new();
        loop {
            let c = self.peek()?;
            self.pos += 1;
            match c {
                b'"' => return String::from_utf8(out).ok(),
                b'\\' => {
                    let e = self.peek()?;
                    self.pos += 1;
                    let ch = match e {
                        b'"' => '"',
                        b'\\' => '\\',
                        b'/' => '/',
                        b'b' => '\u{8}',
                        b'f' => '\u{c}',
                        b'n' => '\n',
                        b'r' => '\r',
                        b't' => '\t',
                        b'u' => {
                            let hi = self.hex4()?;
                            let code = if (0xD800..=0xDBFF).contains(&hi) {
                                if self.b.get(self.pos) != Some(&b'\\') || self.b.get(self.pos + 1) != Some(&b'u') {
                                    return None;
                                }
                                self.pos += 2;
                                let lo = self.hex4()?;
                                if !(0xDC00..=0xDFFF).contains(&lo) {
                                    return None;
                                }
                                0x10000 + ((hi - 0xD800) << 10) + (lo - 0xDC00)
                            } else {
                                hi
                            };
                            // A lone low surrogate has no char and is refused here.
                            char::from_u32(code)?
                        }
                        _ => return None,
                    };
                    let mut buf = [0u8; 4];
                    out.extend_from_slice(ch.encode_utf8(&mut buf).as_bytes());
                }
                0x00..=0x1f => return None,
                other => out.push(other),
            }
        }
    }

    fn digits(&mut self) -> usize {
        let start = self.pos;
        while self.pos < self.b.len() && self.b[self.pos].is_ascii_digit() {
            self.pos += 1;
        }
        self.pos - start
    }

    fn number(&mut self) -> Option<Value> {
        let start = self.pos;
        if self.peek() == Some(b'-') {
            self.pos += 1;
        }
        match self.peek()? {
            b'0' => self.pos += 1,
            b'1'..=b'9' => {
                self.digits();
            }
            _ => return None,
        }
        let mut is_float = false;
        if self.peek() == Some(b'.') {
            self.pos += 1;
            if self.digits() == 0 {
                return None;
            }
            is_float = true;
        }
        if matches!(self.peek(), Some(b'e') | Some(b'E')) {
            self.pos += 1;
            if matches!(self.peek(), Some(b'+') | Some(b'-')) {
                self.pos += 1;
            }
            if self.digits() == 0 {
                return None;
            }
            is_float = true;
        }
        let text = std::str::from_utf8(&self.b[start..self.pos]).ok()?;
        if !is_float {
            if let Ok(i) = text.parse::<i64>() {
                return if (i as f64).abs() <= MAX_SAFE_F64 { Some(Value::Int(i)) } else { None };
            }
        }
        let f: f64 = text.parse().ok()?;
        if !f.is_finite() || (f.fract() == 0.0 && f.abs() > MAX_SAFE_F64) {
            return None;
        }
        Some(Value::Float(f))
    }
}

fn parse_object(segment: &str) -> Option<Value> {
    let bytes: Vec<u8> = base64_url_decode(segment).into_iter().map(|b| b as u8).collect();
    let text = String::from_utf8(bytes).ok()?;
    let mut parser = Parser { b: text.as_bytes(), pos: 0 };
    parser.ws();
    if parser.peek() != Some(b'{') {
        return None;
    }
    let value = parser.value(1)?;
    parser.ws();
    if parser.pos != parser.b.len() {
        return None;
    }
    Some(value)
}

/// (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.
pub fn split_jwt(token: &str) -> Option<(Value, Value, String, Vec<i64>)> {
    let parts: Vec<&str> = token.split('.').collect();
    if parts.len() != 3 {
        return None;
    }
    if !is_segment(parts[0], false) || !is_segment(parts[1], false) || !is_segment(parts[2], true) {
        return None;
    }
    let header = parse_object(parts[0])?;
    let claims = parse_object(parts[1])?;
    Some((header, claims, format!("{}.{}", parts[0], parts[1]), base64_url_decode(parts[2])))
}

/// RFC 7518 section 3.2: an HS256 key MUST be at least 256 bits.
///
/// # Panics
/// Panics on a value outside 0-255 or fewer than 32 bytes.
pub fn check_jwt_secret(secret: &[i64]) {
    if secret.iter().any(|b| !(0..=255).contains(b)) {
        panic!("secret must be a list of integers from 0 to 255");
    }
    if secret.len() < 32 {
        panic!(
            "secret must be at least 32 bytes (RFC 7518 section 3.2), received {}",
            secret.len()
        );
    }
}

/// 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.
pub fn decode_jwt(token: &str) -> Option<Value> {
    split_jwt(token).map(|(_, claims, _, _)| claims)
}

pub fn fune_vector(args: &[Value]) -> Value {
    match &args[0] {
        Value::Str(token) => decode_jwt(token).unwrap_or(Value::Null),
        _ => Value::Null,
    }
}

Install

fune build

With that line in your source, in a Rust project (language rust in fune.project), fune build resolves it and its 4 dependencies, pins them in fune.lock, downloads only the Rust 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. A crate’s build.rs runs it before every compile. 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 Rust auth.jwt-1.0.0-rust.fune · 54,955 bytes sha256 75ce6303b7f3e811bd186fb266b8af87dbca2adc39f12401e6d0d863668a0c74

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

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