Home › Security › Authentication & authorization
algorithm low

JSON Web Token (JWT)

Problem A server must accept requests from clients or other services it holds no stored state for, and decide who sent them, for whom they are meant and until when, without a database lookup per request.

A signed, URL-safe token carrying claims; the verifier's job is the checklist (pinned algorithm, key by kid, exp, aud, iss), not the format.

verify O(n) in token size: one hash pass plus one MAC or signature check; claim checks O(1)
jwsjwtjwksclaimsbearer-tokenkey-rotationrfc-7519
Overview

A JWT is three base64url parts joined by dots: JSON header, JSON claims, and a MAC or signature over the first two. Whoever holds the verification key can check, with no lookup, who issued the claims, for which audience and until when. Login providers, service meshes, device fleets and clinical-data APIs use it so. The format is easy; the security lives in what the verifier refuses.

Key facts

Use when

  • Many verifiers, one issuer: OpenID Connect ID tokens, service-to-service calls in a cluster, device uplinks.
  • Short-lived grants: download or stream entitlements, game or session tickets, patient-app access.

Avoid when

  • You need instant logout or per-request revocation: server sessions.
  • The claims are secret or large (every request carries them).
  • One service owns both ends: a random session id is simpler.

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.

  1. Split on . into exactly three parts, decode strictly (unpadded base64url, RFC 7515 section 3.1); the signing input is the received header.payload text, never re-serialized JSON.
  2. Read the header; reject any crit entry you do not implement (RFC 7515 section 4.1.11).
  3. Pick the key by kid from keys you configured for this issuer. Ignore jku, jwk, x5u: they let the sender name its own key (RFC 8725 section 3.10).
  4. Each key record carries exactly one algorithm; reject unless the header alg equals it. none and any mismatch die here.
  5. Verify the MAC (constant-time compare) or signature over the signing input. Only now parse the claims.
  6. Time: reject if exp is missing or now >= exp + skew, or if now + skew < nbf; skew is seconds to a few minutes (RFC 7519 sections 4.1.4, 4.1.5).
  7. Identity: iss is the issuer the key belongs to; aud contains your identifier; typ or required claims match this kind of token (RFC 8725 sections 3.8, 3.9, 3.11, 3.12).
  8. Optional: reject a denylisted or reused jti. Then authorize on sub and 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.
    • EdDSA or ES256 for small tokens; RS256 where verifies far outnumber signs — token size, verify cost — asymmetric choice.
  • 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 kid as 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, accepting none, or one key used for both HS and RS verification (RFC 8725 section 2.1).
  • Following jku, jwk or x5u from the token (RFC 8725 section 3.10).
  • No aud check: a token for service A replays at service B of the same issuer. No typ or claim split: an ID token accepted as an access token (RFC 8725 section 2.8).
  • exp is 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

Server sessionsinstant logout; see session vs token auth.
Opaque token plus introspection (RFC 7662)central revocation, one extra call per verify.
CWT (RFC 8392) over COSEsame claims in CBOR, smaller for constrained devices.Signed URLsone resource and expiry, no claims.

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

namelangrepoinstallverdictlicencehealthas_ofnotes
jsonwebtokenRustKeats/jsonwebtokencargo add jsonwebtoken————Validation pins algorithms; exp, nbf, aud, iss, leeway
golang-jwtGogolang-jwt/jwtgo get github.com/golang-jwt/jwt/v5————WithValidMethods pins algorithms
jose (panva)JSpanva/josenpm install jose————JWS, JWT, remote JWKS; also JWE
PyJWTPythonjpadilla/pyjwtpip install PyJWT————decode(..., algorithms=[...]) required; PyJWKClient
lestrrat-go/jwxGolestrrat-go/jwxgo 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

Used by

Systems (19):

Alternatives and related

HKDF key derivation (extract-and-expand)A system holds one shared secret, such as a key-agreement result or a master key, but needs many independent keys from it (per direction, purpose, file, device or epoch), and the secret itself is not uniformly random.Message authentication codes (HMAC, CMAC, Poly1305, KMAC)A receiver gets bytes over a channel an attacker can modify (webhook, cookie, radio frame, download link) and must tell whether a holder of a shared secret produced them unchanged.Digital signatures (Ed25519)Services, release pipelines and protocols need recipients to verify who produced a message and that it was not altered, without sharing a secret with every verifier (tokens, releases, webhooks).ECDSA signaturesAnyone who must prove that a message, certificate or transaction came from a key holder, using a standard that hardware, browsers and ledgers already implement, needs a short public-key proof with no shared secret.
rsa cryptosystem · plannedRSA key generation, OAEP encryption and PSS/PKCS#1 v1.5 signatures, key sizes and padding pitfalls
key rotation · plannedRotating keys without downtime: key ids, overlap windows, re-wrapping, JWKS publication, revocation
Signed URLsA service must let a client with no account or session fetch or upload exactly one object for a few minutes, without the bytes passing through the app server and without storing a grant per link.Replay protection windowReceivers of authenticated messages (VPN packets, voice frames, sensor readings, signed API calls) must reject copies that an attacker or the network re-sends, though every copy carries a valid tag and messages may arrive out of order.
constant time code · plannedWriting secret-dependent code without branches or memory lookups on secrets; constant-time compare and select
capability tokens · plannedUnforgeable, attenuable tokens granting specific rights (macaroons, biscuits, object capabilities)
cbor cose · plannedCBOR binary encoding and COSE signing/encryption structures (WebAuthn, C2PA, attestation)