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.
- sign_jwt (claims: record, secret: int[]) -> string
- verify_jwt (token: string, secret: int[], now: int, leewaySeconds: int) -> JwtVerification
- 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
| claims | record | a JSON object; numbers must be whole and within 2^53, keys are written in sorted order |
| secret | int[] | at least 32 bytes (RFC 7518 section 3.2); encoding.utf8 for a text secret |
| returns | string | header.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 claimssign_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 tokenssign_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(…)
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
| token | string | the compact JWT, as parsed from the Authorization header |
| secret | int[] | the key it should be signed with, at least 32 bytes |
| now | int | the current time in Unix seconds, read by the caller |
| leeway_seconds | int | clock skew allowed on exp, nbf and iat, 0 or more; 30 to 60 is usual |
| returns | JwtVerification | valid 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 expverify_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(…)
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>
| token | string | a compact JWT |
| returns | record? | 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 claimsdecode_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 madedecode_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(…)
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
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
| Case | Arguments | Expected | |
|---|---|---|---|
| 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
| Case | Arguments | Expected | |
|---|---|---|---|
| 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
| Case | Arguments | Expected | |
|---|---|---|---|
| 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
| Case | Arguments | Expected | |
|---|---|---|---|
| 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
| Case | Arguments | Expected | |
|---|---|---|---|
| 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
| Case | Arguments | Expected | |
|---|---|---|---|
| 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
| Path | Bytes |
|---|---|
| README.md | 4,170 |
| impl/python/decode_jwt.py | 4,227 |
| impl/python/sign_jwt.py | 2,396 |
| impl/python/verify_jwt.py | 2,679 |
| impl/rust/decode_jwt.rs | 10,817 |
| impl/rust/sign_jwt.rs | 4,201 |
| impl/rust/verify_jwt.rs | 4,554 |
| impl/typescript/decode_jwt.ts | 4,863 |
| impl/typescript/sign_jwt.ts | 3,412 |
| impl/typescript/verify_jwt.ts | 2,879 |
| vectors.json | 23,930 |