santoku-jwt
View on LuaRocksJSON Web Tokens over a pluggable crypto backend: ES256 everywhere through santoku-monocypher, RS256 under OpenResty, claim checks, and cached remote JWKS
santoku-jwt signs, parses and verifies JSON Web Tokens, checks their claims, and builds and caches JWKS documents. The core is plain Lua and takes a crypto backend, the way santoku-http takes a socket backend: require("santoku.jwt")(backend). santoku.jwt.monocypher signs and verifies ES256 natively, in WebAssembly and under OpenResty, so the examples on this page run in your browser. santoku-resty's santoku.resty.jwt.openssl signs and verifies ES256 and RS256 inside an nginx worker. Private keys are JWKs, so a key made by one backend works in the other.
Keys, signing and verifying
keypair(alg) returns a private JWK and its public JWK; alg defaults to ES256. The public JWK carries kty, crv, x, y, alg, use sig, and the RFC 7638 thumbprint as kid. sign(claims, private) returns a compact token whose header names the private key's alg and kid. jwks(jwk, ...) wraps public JWKs into the { keys } document you serve, and keyset(doc) turns one into the kid lookup verify takes. verify(token, get_jwk[, opts]) returns true and { header, payload }, or false and a reason. Keep the private JWK out of the repo: its d member is the secret.
local utc = require("santoku.utc")
local jwt = require("santoku.jwt")(require("santoku.jwt.monocypher"))
local private, jwk = jwt.keypair()
print("kid:", jwk.kid, "has d:", jwk.d ~= nil)
local token = jwt.sign({ sub = "u1", aud = "app", exp = utc.time() + 3600 }, private)
local ok, data = jwt.verify(token, jwt.keyset(jwt.jwks(jwk)))
print("verified:", ok, "sub:", data.payload.sub, "alg:", data.header.alg)
return ok
Claims and rejections
Without opts.claims, verify requires exp to be a number after the current time. claims({ aud, iss, leeway, time }) builds a claim spec that also checks aud (a string, or an array containing it), iss, and nbf when present, allowing leeway seconds of clock skew; pass it as opts.claims. opts.time replaces the backend's clock, and opts.algs narrows the accepted algorithms. A failed check returns false and its message: expired, not yet valid, wrong audience, wrong issuer. Before the claims, verify rejects with Malformed token, Unsupported alg (including none and every HMAC alg), No kid, No jwk for kid, Unsupported key type, Key alg mismatch, or Bad signature.
local utc = require("santoku.utc")
local jwt = require("santoku.jwt")(require("santoku.jwt.monocypher"))
local private, jwk = jwt.keypair()
local keys = jwt.keyset(jwt.jwks(jwk))
local now = utc.time()
local spec = jwt.claims({ aud = "app", iss = "https://auth.example", leeway = 30 })
local good = jwt.sign({ aud = "app", iss = "https://auth.example", exp = now + 60 }, private)
print("good:", jwt.verify(good, keys, { claims = spec }))
print("other aud:", jwt.verify(jwt.sign({ aud = "x", iss = "https://auth.example", exp = now + 60 }, private), keys, { claims = spec }))
print("expired:", jwt.verify(jwt.sign({ exp = now - 1 }, private), keys))
print("rs256 only:", jwt.verify(good, keys, { algs = { "RS256" } }))
return select(2, jwt.verify("not.a.token", keys))
parse and the RFC 7515 example
parse(token) splits a token without verifying it and returns header, payload, signing_input and the raw signature, or nil and Malformed token. A backend's verify(alg, jwk, signing_input, signature) checks one signature directly. The token and key below are RFC 7515's ES256 example (appendix A.3); its header has no kid, so verify itself would answer No kid.
local backend = require("santoku.jwt.monocypher")
local jwt = require("santoku.jwt")(backend)
local obj = jwt.parse("eyJhbGciOiJFUzI1NiJ9"
.. ".eyJpc3MiOiJqb2UiLA0KICJleHAiOjEzMDA4MTkzODAsDQogImh0dHA6Ly9leGFtcGxlLmNvbS9pc19yb290Ijp0cnVlfQ"
.. ".DtEhU3ljbEg8L38VWAfUAqOyKAM6-Xx-F4GawxaepmXFCgfTjDxw5djxLa8ISlSApmWQxfKTUJqPP3-Kg6NU1Q")
print("iss:", obj.payload.iss, "exp:", obj.payload.exp)
return backend.verify("ES256", {
kty = "EC", crv = "P-256",
x = "f83OJ3D2xF1Bg8vub9tLe1gHMzV76e8Tus9uPHvRVEU",
y = "x_FEzRu9m36HLN_tue659LNpXW6pCyStikYjKIWI5a0",
}, obj.signing_input, obj.signature)
A remote JWKS
remote_jwks(url, opts) returns a kid lookup backed by a fetched JWKS. Pass opts.client, any santoku-http client, or opts.fetch(url) returning status and body. It refetches after opts.ttl seconds (3600) and on an unknown kid at most once per opts.min_interval seconds (60), and keeps the last good set when a fetch fails or returns a document without a keys array. Callers that arrive while a fetch is in flight wait on the backend's lock for up to opts.wait seconds (10). Create the lookup once and reuse it, or every call starts with an empty cache.
local http = require("santoku.http")(require("santoku.socket"))
local jwt = require("santoku.jwt")(require("santoku.jwt.monocypher"))
local certs = jwt.remote_jwks("https://auth.example/.well-known/jwks.json", { client = http })
local ok, data = jwt.verify(token, certs, { claims = jwt.claims({ aud = "app" }) })
print(ok, ok and data.payload.sub or data)
Backends
A backend is a table: algs lists what it handles, and the core refuses any other alg before calling it. sign(alg, private, data) returns the raw signature (for ES256, the 64-byte r and s that JWS uses). verify(alg, jwk, data, sig) returns a boolean. keypair(alg, opts) returns a private JWK. now() returns epoch seconds. lock() returns { wait(seconds), release() } for remote_jwks; under OpenResty it is an ngx.semaphore so concurrent requests share one fetch, and elsewhere a no-op. santoku.resty.jwt.openssl implements ES256 and RS256 on the OpenSSL linked into nginx; its keypair("RS256", { bits }) defaults to 2048 bits.
local jwt = require("santoku.jwt")(require("santoku.resty.jwt.openssl"))
local private, jwk = jwt.keypair("RS256")
local token = jwt.sign({ sub = "u1", exp = ngx.time() + 3600 }, private)
ngx.say(jwt.verify(token, jwt.keyset(jwt.jwks(jwk))))