Deep dive
How it works
In: token string, issuer's JWKS (keys by kid), policy (pinned alg per key, iss, aud, now, skew) · Out: claims or a rejection reason · State: cached JWKS, optional jti denylist · Cost: O(n) hash plus one MAC/signature check; HS256 about 1.3 µs for the MAC here.
- Split on
.into exactly three parts, decode strictly (unpadded base64url, RFC 7515 section 3.1); the signing input is the receivedheader.payloadtext, never re-serialized JSON. - Read the header; reject any
critentry you do not implement (RFC 7515 section 4.1.11). - Pick the key by
kidfrom keys you configured for this issuer. Ignorejku,jwk,x5u: they let the sender name its own key (RFC 8725 section 3.10). - Each key record carries exactly one algorithm; reject unless the header
algequals it.noneand any mismatch die here. - Verify the MAC (constant-time compare) or signature over the signing input. Only now parse the claims.
- Time: reject if
expis missing ornow >= exp + skew, or ifnow + skew < nbf; skew is seconds to a few minutes (RFC 7519 sections 4.1.4, 4.1.5). - Identity:
issis the issuer the key belongs to;audcontains your identifier;typor required claims match this kind of token (RFC 8725 sections 3.8, 3.9, 3.11, 3.12). - Optional: reject a denylisted or reused
jti. Then authorize onsuband scope.
Registered claims (RFC 7519 section 4.1): iss, sub, aud (string or array), exp, nbf, iat (seconds since 1970), jti. All optional in the RFC; your profile makes exp, iss, aud mandatory.
Algorithms (RFC 7518 section 3; EdDSA from RFC 8037): HS256 HMAC-SHA-256, key at least 256 bit; RS256 RSASSA-PKCS1-v1_5; ES256 ECDSA P-256, signature is R and S concatenated (64 B), not DER; EdDSA with Ed25519. Use asymmetric keys when verifiers must not mint tokens.
Key rotation (JWKS, RFC 7517): the issuer publishes {"keys":[…]}, each key with a distinct kid. Publish the new key, start signing with it, keep the old one listed until old tokens have expired plus verifiers' cache time. Verifiers refetch (rate-limited) on an unknown kid.
Step 4 ends two classic attacks: alg flipped to none so a lenient library skips verification, and RS256 flipped to HS256 so it MACs with the public RSA key as the secret (RFC 8725 section 2.1).
Code
Rust, std only; SHA-256 constants are derived from the primes. A naive field scanner stands in for JSON; use a parser in production. Checked against SHA-256("abc"), RFC 4231 cases 1 and 6, the RFC 7515 A.1 token and one assert per rejection path.
use std::sync::OnceLock;
// ---- SHA-256 and HMAC (FIPS 180-4, RFC 2104) from primitives ----
fn frac32(x: f64) -> u32 { ((x - x.floor()) * 4294967296.0) as u32 }
fn consts() -> &'static ([u32; 8], [u32; 64]) {
static C: OnceLock<([u32; 8], [u32; 64])> = OnceLock::new();
C.get_or_init(|| {
let p: Vec<u64> = (2u64..).filter(|n| (2..*n).take_while(|d| d * d <= *n).all(|d| n % d != 0)).take(64).collect();
let (mut h, mut k) = ([0u32; 8], [0u32; 64]);
for i in 0..8 { h[i] = frac32((p[i] as f64).sqrt()); } // sqrt of first 8 primes
for i in 0..64 { k[i] = frac32((p[i] as f64).cbrt()); } // cbrt of first 64 primes
(h, k)
})
}
pub fn sha256(msg: &[u8]) -> [u8; 32] {
let (h0, k) = consts();
let mut h = *h0;
let mut m = msg.to_vec();
m.push(0x80);
while m.len() % 64 != 56 { m.push(0); }
m.extend((msg.len() as u64 * 8).to_be_bytes());
for c in m.chunks(64) {
let mut w = [0u32; 64];
for i in 0..16 { w[i] = u32::from_be_bytes(c[4 * i..4 * i + 4].try_into().unwrap()); }
for i in 16..64 {
let s0 = w[i - 15].rotate_right(7) ^ w[i - 15].rotate_right(18) ^ (w[i - 15] >> 3);
let s1 = w[i - 2].rotate_right(17) ^ w[i - 2].rotate_right(19) ^ (w[i - 2] >> 10);
w[i] = w[i - 16].wrapping_add(s0).wrapping_add(w[i - 7]).wrapping_add(s1);
}
let mut v = h;
for i in 0..64 {
let s1 = v[4].rotate_right(6) ^ v[4].rotate_right(11) ^ v[4].rotate_right(25);
let ch = (v[4] & v[5]) ^ (!v[4] & v[6]);
let t1 = v[7].wrapping_add(s1).wrapping_add(ch).wrapping_add(k[i]).wrapping_add(w[i]);
let s0 = v[0].rotate_right(2) ^ v[0].rotate_right(13) ^ v[0].rotate_right(22);
let t2 = s0.wrapping_add((v[0] & v[1]) ^ (v[0] & v[2]) ^ (v[1] & v[2]));
v = [t1.wrapping_add(t2), v[0], v[1], v[2], v[3].wrapping_add(t1), v[4], v[5], v[6]];
}
for i in 0..8 { h[i] = h[i].wrapping_add(v[i]); }
}
let mut o = [0u8; 32];
for i in 0..8 { o[4 * i..4 * i + 4].copy_from_slice(&h[i].to_be_bytes()); }
o
}
pub fn hmac_sha256(key: &[u8], msg: &[u8]) -> [u8; 32] {
let mut k0 = [0u8; 64];
if key.len() > 64 { k0[..32].copy_from_slice(&sha256(key)); } else { k0[..key.len()].copy_from_slice(key); }
let mut inner: Vec<u8> = k0.iter().map(|b| b ^ 0x36).collect();
inner.extend(msg);
let mut outer: Vec<u8> = k0.iter().map(|b| b ^ 0x5c).collect();
outer.extend(sha256(&inner));
sha256(&outer)
}
// ---- base64url without padding (RFC 4648 section 5); encoder omitted ----
const A: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
pub fn b64d(s: &str) -> Option<Vec<u8>> {
if s.len() % 4 == 1 { return None; }
let (mut out, mut acc, mut bits) = (Vec::new(), 0u32, 0);
for b in s.bytes() { // '=' and '+' '/' are not in the alphabet: rejected
acc = acc << 6 | A.iter().position(|&a| a == b)? as u32;
bits += 6;
if bits >= 8 { bits -= 8; out.push((acc >> bits) as u8); acc &= (1 << bits) - 1; }
}
if acc != 0 { return None; } // non-zero spare bits: non-canonical, token string would be malleable
Some(out)
}
// ---- the verifier's checklist (RFC 7519, RFC 8725) ----
#[derive(Debug, PartialEq)]
pub enum Err { Malformed, Alg, Crit, Sig, Expired, NotYet, Iss, Aud }
pub struct Policy<'a> { pub iss: &'a str, pub aud: &'a str, pub now: u64, pub skew: u64 }
// Naive top-level lookup of a JSON member; use a real parser in production.
fn field<'a>(j: &'a str, k: &str) -> Option<&'a str> {
let i = j.find(&format!("\"{k}\""))? + k.len() + 2;
let r = j[i..].trim_start().strip_prefix(':')?.trim_start();
let end = if let Some(s) = r.strip_prefix('"') { s.find('"')? + 2 } else if r.starts_with('[') { r.find(']')? + 1 }
else { r.find(|c: char| ",} \r\n".contains(c))? };
Some(r[..end].trim_matches('"'))
}
pub fn verify(tok: &str, key: &[u8], p: &Policy) -> Result<String, Err> {
let parts: Vec<&str> = tok.split('.').collect();
if parts.len() != 3 { return Err(Err::Malformed); }
let hdr = String::from_utf8(b64d(parts[0]).ok_or(Err::Malformed)?).map_err(|_| Err::Malformed)?;
if field(&hdr, "alg") != Some("HS256") { return Err(Err::Alg); } // pinned for this key; "none" lands here
if hdr.contains("\"crit\"") { return Err(Err::Crit); } // we implement no extensions
let sig = b64d(parts[2]).ok_or(Err::Malformed)?;
let mac = hmac_sha256(key, tok[..parts[0].len() + 1 + parts[1].len()].as_bytes());
// constant-time compare, length is public
if sig.len() != 32 || mac.iter().zip(&sig).fold(0, |a, (x, y)| a | (x ^ y)) != 0 { return Err(Err::Sig); }
let pl = String::from_utf8(b64d(parts[1]).ok_or(Err::Malformed)?).map_err(|_| Err::Malformed)?;
let num = |k| field(&pl, k).and_then(|v| v.parse::<u64>().ok());
if p.now >= num("exp").ok_or(Err::Expired)? + p.skew { return Err(Err::Expired); } // exp mandatory
if num("nbf").map_or(false, |n| p.now + p.skew < n) { return Err(Err::NotYet); }
if field(&pl, "iss") != Some(p.iss) { return Err(Err::Iss); }
let aud = field(&pl, "aud").ok_or(Err::Aud)?;
if !aud.split(',').any(|a| a.trim_matches(|c| "[]\" ".contains(c)) == p.aud) { return Err(Err::Aud); }
Ok(pl)
}
Tests (helpers b64e, sign omitted): the RFC 7515 A.1 token passes signature and exp at now = 1300819000 and fails only on its missing aud; at now = 1300819380 it is Expired.
Complexity and constants
Verification reads each byte once; HMAC-SHA256 over the 111 B signing input is 5 compressions (3 with cached pads). Base64url adds 33 %: the signature is 43 chars for HS256, 86 for ES256 or EdDSA, 342 for RSA-2048. The token rides on every request, so its bytes are the network cost.
Efficiency
- Cost: measured on an M4 Pro, release build, the code above: about 2.3 µs per verify of a 155 B HS256 token, 1.2-1.3 µs of it the HMAC (heap copies, no cached pads, no SHA hardware). Bound: compute; a JWKS fetch per request would be network-bound.
- Levers:
- Cache JWKS and parsed keys by
kid— removes a network round trip per request — always. - Cache the HMAC pad states per key — saves 2 of 5 compressions here (see MACs) — HS256.
- Verify once at the edge, pass identity inward on a trusted channel — N hops become 1 verification — gateways, meshes.
EdDSAorES256for small tokens;RS256where verifies far outnumber signs — token size, verify cost — asymmetric choice.
- Cache JWKS and parsed keys by
- Naive → standard → state of the art:
- Fetch JWKS and parse it on every request · milliseconds plus an outage dependency
- Cached keys by
kid, one verify per request · about 1 µs (HS256), order of tens of µs (asymmetric) - Gateway verifies once and forwards identity · one verify per user action, not per hop
Implementation notes
- Compare decoded MAC bytes in constant time (constant-time code); length is public.
- Decode canonically: reject
=,+,/and non-zero spare bits in the last character. Otherwise 3 extra encodings of one 43-char signature verify but differ as strings, which defeats a denylist keyed on the token text (found while testing the code above). - Treat
kidas an opaque map key, never part of a path or query. Derive HS256 keys per purpose (HKDF); never use a password (RFC 8725 section 3.5).
Pitfalls
- Trusting header
alg, acceptingnone, or one key used for both HS and RS verification (RFC 8725 section 2.1). - Following
jku,jwkorx5ufrom the token (RFC 8725 section 3.10). - No
audcheck: a token for service A replays at service B of the same issuer. Notypor claim split: an ID token accepted as an access token (RFC 8725 section 2.8). expis optional in the RFC: reject tokens without it.- Reading claims before the signature; secrets in the payload (it is only signed); tokens in script-readable storage.
Variants and alternatives
In production
- OpenID Connect: the ID Token is a JWT, verified with the provider's JWKS (OIDC Core 1.0).
- Kubernetes: service-account tokens are JWTs that other services can verify against the cluster's published keys.
- HL7 SMART: confidential asymmetric clients authenticate with a signed JWT (
private_key_jwt, RFC 7523). - JWT access tokens for OAuth 2.0, profiled in RFC 9068.
Implementations
| name | lang | repo | install | verdict | licence | health | as_of | notes |
|---|---|---|---|---|---|---|---|---|
| jsonwebtoken | Rust | Keats/jsonwebtoken | cargo add jsonwebtoken | — | — | — | — | Validation pins algorithms; exp, nbf, aud, iss, leeway |
| golang-jwt | Go | golang-jwt/jwt | go get github.com/golang-jwt/jwt/v5 | — | — | — | — | WithValidMethods pins algorithms |
| jose (panva) | JS | panva/jose | npm install jose | — | — | — | — | JWS, JWT, remote JWKS; also JWE |
| PyJWT | Python | jpadilla/pyjwt | pip install PyJWT | — | — | — | — | decode(..., algorithms=[...]) required; PyJWKClient |
| lestrrat-go/jwx | Go | lestrrat-go/jwx | go get github.com/lestrrat-go/jwx/v3 | — | — | — | — | JWK, JWS, JWT; JWKS cache |
Sources
- Jones, Bradley, Sakimura, JSON Web Token (JWT), RFC 7519, 2015. rfc
- Jones, Bradley, Sakimura, JSON Web Signature (JWS), RFC 7515, 2015. rfc
- Jones, JSON Web Key (JWK), RFC 7517, and JSON Web Algorithms (JWA), RFC 7518, 2015. 7517 · 7518
- Liusvaara, CFRG ECDH and Signatures in JOSE, RFC 8037, 2017. rfc
- Sheffer, Hardt, Jones, JSON Web Token Best Current Practices, RFC 8725 (BCP 225), 2020. rfc
- Nystrom, Identifiers and Test Vectors for HMAC-SHA, RFC 4231, 2005. rfc
- RFC 7523 (JWT client authentication) and RFC 9068 (JWT access tokens). 7523 · 9068
- OpenID Foundation, OpenID Connect Core 1.0. spec · Kubernetes, Managing Service Accounts. docs · HL7, SMART App Launch: Confidential Asymmetric Client Authentication. spec