JWT verification library for OpenResty.
- Description
- Install
- Status
- Library non-goals
- Differences from lua-resty-jwt
- Types
- Supported features
- Planned missing features
- Dependencies
- JWT verification usage
- JWKS verification usage
- RFCs used as reference
- Run tests
JWT verification library for OpenResty.
The project's goal is to be a modern and slimmer replacement for lua-resty-jwt with built-in support for JWKS.
This project does not provide JWT manipulation or creation features: you can only verify/decrypt tokens.
Install with OPM (recommended):
opm get anvouk/lua-resty-jwt-verification
Or with luarocks:
luarocks install lua-resty-jwt-verification
Fully working, but looking for more people to take it for a spin and provide feedback.
The APIs should be stable, but I reserve the right to make breaking changes until this project reaches 1.0;
- JWT creation/modification
- Feature complete for the sake of RFCs completeness.
- Senseless and unsafe RFCs features (e.g. alg none) won't be implemented.
Main differences are:
- No JWT manipulation of any kind (you can only decrypt/verify them)
- Simpler internal structure reliant on more recent lua-resty-openssl and OpenSSL versions.
- Supports different JWE algorithms (see tables above).
- Automatic JWT verification given JWKS HTTP endpoint.
If any of the points above are a problem, or you need compatibility with older OpenResty versions, I recommend sticking with lua-resty-jwt.
Types and null checks are provided with extensive use of EmmyLua annotations.
IDEs plugin integrations for EmmyLua:
The file ngx.d.lua
in the project's root provides some ngx
stubs.
- JWS verification: with symmetric or asymmetric keys.
- JWE decryption: with symmetric or asymmetric keys.
- Asymmetric keys format supported:
- PEM
- DER
- JWK
- JWT claims validation.
- Automatic JWKS fetching and JWT validation.
- optional caching strategies.
Claims | Implemented |
---|---|
alg | âś… |
jku | ❌ |
jwk | ❌ |
kid | âś… |
x5u | ❌ |
x5c | ❌ |
x5t | ❌ |
x5t#S256 | ❌ |
typ | âś… |
cty | ❌ |
crit | âś… |
Alg | Implemented |
---|---|
HS256 | âś… |
HS384 | âś… |
HS512 | âś… |
RS256 | âś… |
RS384 | âś… |
RS512 | âś… |
ES256 | âś… |
ES384 | âś… |
ES512 | âś… |
PS256 | âś… |
PS384 | âś… |
PS512 | âś… |
none | ❌ |
Claims | Implemented |
---|---|
alg | âś… |
enc | âś… |
zip | ❌ |
jku | ❌ |
jwk | ❌ |
kid | âś… |
x5u | ❌ |
x5c | ❌ |
x5t | ❌ |
x5t#S256 | ❌ |
typ | âś… |
cty | ❌ |
crit | âś… |
Alg | Implemented | Requirements |
---|---|---|
RSA1_5 | ❌ | |
RSA-OAEP | ❌ | |
RSA-OAEP-256 | ❌ | |
A128KW | âś… | *OpenSSL 3.0+ |
A192KW | âś… | *OpenSSL 3.0+ |
A256KW | âś… | *OpenSSL 3.0+ |
dir | âś… | |
ECDH-ES | ❌ | |
A128GCMKW | ❌ | |
A192GCMKW | ❌ | |
A256GCMKW | ❌ | |
PBES2-HS256+A128KW | ❌ | |
PBES2-HS384+A192KW | ❌ | |
PBES2-HS512+A256KW | ❌ |
*The first official release of OpenResty including OpenSSL 3.0+ is OpenResty 1.27.1.1 which shipped with OpenSSL 3.0.15 (Yes, the godawful slow OpenSSL 3.0 series...).
So, please, go with OpenResty 1.27.1.2 as a minimum, which shipped with OpenSSL 3.4.1.
Enc | Implemented |
---|---|
A128CBC-HS256 | âś… |
A192CBC-HS384 | âś… |
A256CBC-HS512 | âś… |
A128GCM | âś… |
A192GCM | âś… |
A256GCM | âś… |
Cache Strategy | Implemented |
---|---|
no cache | âś… |
local (shared_dict) | âś… |
redis | ❌ |
This is a list of missing features I'd like to implement when given enough time:
- Implement JWE validation with at least 1 asymmetric
alg
. - Nested JWT (i.e. JWT in JWE).
- JWKS Redis cache strategy.
- Automatic JWKS validation for JWE.
luarocks install lua-cjson
luarocks install lua-resty-openssl
luarocks install lua-resty-http
syntax: header, err = jwt.decode_header_unsafe(token)
Read a jwt header and convert it to a lua table.
Important: this method does not validate JWT signature! Only use if you need to inspect the token's header without having to perform the full validation.
local jwt = require("resty.jwt-verification")
local token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJmb28iOiJiYXIiLCJpYXQiOjE3MTY2NDkwNzJ9._MwFdsBPSyci9iARpoAaulReGcn1q7mKiPZjR2JDvdY"
local header, err = jwt.decode_header_unsafe(token)
if not header then
return nil, "malformed jwt: " .. err
end
print("alg: " .. header.alg) -- alg: HS256
syntax: decoded_token, err = jwt.verify(token, secret, options?)
Validate a JWS token and convert it to a lua table.
The optional parameter options
can be passed to configure the token validator. Valid fields are:
valid_signing_algorithms
(dict<string, string> | nil): a dict containing allowedalg
claims used to validate the JWT.typ
(string | nil): if non-null, ensure JWT claimtyp
matches the passed value.issuer
(string | nil): if non-null, ensure JWT claimiss
matches the passed value.audiences
(string | table | nil): if non-null, ensure JWT claimaud
matches one of the supplied values.subject
(string | nil): if non-null, ensure JWT claimsub
matches the passed value.jwtid
(string | nil): if non-null, ensure JWT claimjti
matches the passed value.ignore_not_before
(bool): If true, the JWT claimnbf
will be ignored.ignore_expiration
(bool): If true, the JWT claimexp
will be ignored.current_unix_timestamp
(datetime | nil): the JWTnbf
andexp
claims will be validated against this timestamp. If null, will use the current datetime supplied byngx.time()
.timestamp_skew_seconds
(int):
Default values for options
fields:
local verify_default_options = {
valid_signing_algorithms = {
["HS256"]="HS256", ["HS384"]="HS384", ["HS512"]="HS512",
["RS256"]="RS256", ["RS384"]="RS384", ["RS512"]="RS512",
["ES256"]="ES256", ["ES384"]="ES384", ["ES512"]="ES512",
["PS256"]="PS256", ["PS384"]="PS384", ["PS512"]="PS512",
},
typ = nil,
issuer = nil,
audiences = nil,
subject = nil,
jwtid = nil,
ignore_not_before = false,
ignore_expiration = false,
current_unix_timestamp = nil,
timestamp_skew_seconds = 1,
}
Minimal example with symmetric keys:
local jwt = require("resty.jwt-verification")
local token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJmb28iOiJiYXIiLCJpYXQiOjE3MTY2NTUwMTV9.NuEhIzUuufJgPZ8CmCPnD4Vrw7EnTyWD8bGtYCwuDZ0"
local decoded_token, err = jwt.verify(token, "superSecretKey")
if not decoded_token then
return nil, "invalid jwt: " .. err
end
print(decoded_token.header.alg) -- HS256
print(decoded_token.payload.foo) -- bar
Minimal example with asymmetric keys:
local jwt = require("resty.jwt-verification")
local token = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJmb28iOiJiYXIiLCJpYXQiOjE3MTY2Njg2Mzd9.H6PE-zLizMMqefx8DG4X5glVjyxR9UNT225Tq2yufHhu4k9K0IGttpykjMCG8Ck_4Qt2ezEWIgoiWhSn1rv_zwxe7Pv-B09fDs7h1hbASi5MZ0YVAmK9ID1RCKM_NTBEnPLot_iopKZRj2_J5F7lvXwJDZSzEAFJZdrgjKeBS4saDZAv7SIL9Nk75rdhgY-RgRwsjmTYSksj7eioRJJLHifrMnlQDbdrBD5_Qk5tD6VPcssO-vIVBUAYrYYTa7M7A_v47UH84zDtzNYBbk9NrDbyq5-tYs0lZwNhIX8t-0VAxjuCyrrGZvv8_O01pdi90kQmntFIbaiDiD-1WlGcGA"
local decoded_token, err = jwt.verify(token, "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAvXFhNyhFWuWtFSJqfOAw\np42lLIn9kB9oaciiKgNAYZ8SYw5t9Fo+Zh7IciVijn+cVS2/aoBNg2HhfdYgfpQ/\nsb6jwbRqFMln2GmG+X2aJ2wXMJ/QfxrPFdO9L36bAEwkubUTYXwgMSm1KqWRN8xX\n+oBu+dbyzw7iUbrmw0ybzXKZLJvetCvmt0reU5TvdwoczOWFBSKeYnzBrC6hISD8\n8TYDJ4tiw1EWVOupQGqgel0KjC7iwdIYi7PROn6/1MMnF48zlBbT/7/zORj84Z/y\nDnmxZu1MQ07kHqXDRYumSfCerg5Xw5vde7Tz8O0TWtaYV3HJXNa0VpN5OI3L4y7P\nhwIDAQAB\n-----END PUBLIC KEY-----")
if not decoded_token then
return nil, "invalid jwt: " .. err
end
print(decoded_token.header.alg) -- RS256
print(decoded_token.payload.foo) -- bar
Examples with custom options
:
local jwt = require("resty.jwt-verification")
local token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJmb28iOiJiYXIiLCJpYXQiOjE3MTY2NTUwMTV9.NuEhIzUuufJgPZ8CmCPnD4Vrw7EnTyWD8bGtYCwuDZ0"
local decoded_token, err = jwt.verify(token, "superSecretKey", {
valid_signing_algorithms = {["HS256"]="HS256", ["HS384"]="HS384", ["HS512"]="HS512"}, -- only allow HS family algs
audiences = {"user", "admin"}, -- `aud` must be one of the following
ignore_not_before = true -- ignore `nbf` claim (not recommended)
})
if not decoded_token then
return nil, "invalid jwt: " .. err
end
print(decoded_token.header.alg) -- HS256
print(decoded_token.payload.foo) -- bar
syntax: decoded_token, err = jwt.decrypt(token, secret, options?)
Decrypt and validate a JWE token and convert it to a lua table.
The optional parameter options
can be passed to configure the token validator. Valid fields are:
valid_encryption_alg_algorithms
(dict<string, string> | nil): a dict containing allowedalg
claims used to decrypt the JWT.valid_encryption_enc_algorithms
(dict<string, string> | nil): a dict containing allowedenc
claims used to decrypt the JWT.typ
(string | nil): if non-null, ensure JWT claimtyp
matches the passed value.issuer
(string | nil): if non-null, ensure JWT claimiss
matches the passed value.audiences
(string | table | nil): if non-null, ensure JWT claimaud
matches one of the supplied values.subject
(string | nil): if non-null, ensure JWT claimsub
matches the passed value.jwtid
(string | nil): if non-null, ensure JWT claimjti
matches the passed value.ignore_not_before
(bool): If true, the JWT claimnbf
will be ignored.ignore_expiration
(bool): If true, the JWT claimexp
will be ignored.current_unix_timestamp
(datetime | nil): the JWTnbf
andexp
claims will be validated against this timestamp. If null, will use the current datetime supplied byngx.time()
.timestamp_skew_seconds
(int):
Default values for options
fields:
local decrypt_default_options = {
valid_encryption_alg_algorithms = {
["A128KW"]="A128KW", ["A192KW"]="A192KW", ["A256KW"]="A256KW",
["dir"]="dir",
},
valid_encryption_enc_algorithms = {
["A128CBC-HS256"]="A128CBC-HS256",
["A192CBC-HS384"]="A192CBC-HS384",
["A256CBC-HS512"]="A256CBC-HS512",
["A128GCM"]="A128GCM",
["A192GCM"]="A192GCM",
["A256GCM"]="A256GCM",
},
typ = nil,
issuer = nil,
audiences = nil,
subject = nil,
jwtid = nil,
ignore_not_before = false,
ignore_expiration = false,
current_unix_timestamp = nil,
timestamp_skew_seconds = 1,
}
Minimal example with symmetric keys:
local jwt = require("resty.jwt-verification")
local token = "eyJhbGciOiJBMTI4S1ciLCJlbmMiOiJBMTI4Q0JDLUhTMjU2In0.zAIq7qVAEO-eCG6gOdd3ld8_IHzeq3UlaWLHF2IDn6nNUuHh5n_i4w.5CM864cgiBgFPwluW4ViRg.mUeX7zHDVNsXhys0XO5S4w.t3yAR_HU0GDTEyCbpRa6BQ"
local decoded_token, err = jwt.decrypt(token, "superSecretKey12")
if not decoded_token then
return nil, "invalid jwt: " .. err
end
print(decoded_token.header.alg) -- A128KW
print(decoded_token.header.enc) -- A128CBC-HS256
print(decoded_token.payload.foo) -- bar
Minimal example with asymmetric keys:
TODO: not implemented
Examples with custom options
:
local jwt = require("resty.jwt-verification")
local token = "eyJhbGciOiJBMTI4S1ciLCJlbmMiOiJBMTI4Q0JDLUhTMjU2In0.zAIq7qVAEO-eCG6gOdd3ld8_IHzeq3UlaWLHF2IDn6nNUuHh5n_i4w.5CM864cgiBgFPwluW4ViRg.mUeX7zHDVNsXhys0XO5S4w.t3yAR_HU0GDTEyCbpRa6BQ"
local decoded_token, err = jwt.decrypt(token, "superSecretKey12", {
valid_encryption_alg_algorithms = {["A128KW"]="A128KW"}, -- only allow A128KW family algs (requires OpenSSL 3.0+)
valid_encryption_enc_algorithms = {["A128CBC-HS256"]="A128CBC-HS256"}, -- only allow A128CBC family encs
audiences = {"user", "admin"}, -- `aud` must be one of the following
ignore_not_before = true -- ignore `nbf` claim (not recommended)
})
if not decoded_token then
return nil, "invalid jwt: " .. err
end
print(decoded_token.header.alg) -- A128KW
print(decoded_token.header.enc) -- A128CBC-HS256
print(decoded_token.payload.foo) -- bar
The resty.jwt-verification-jwks
module implements automatic JWKS retrieval from an HTTP endpoint and subsequent JWT
validation with fetched keys.
The resty.jwt-verification-jwks-cache-*
modules implement optional JWKS caching strategies. Only one caching strategy
can be enabled at a time; if none are enabled, the JWKS endpoint will be called once for every JWT to validate.
syntax: ok, err = jwks.init(cache_strategy?)
Initialize the jwks module and optionally specify a caching strategy.
This function must be called only once and preferably in the init_by_lua_file
section.
local jwks = require("resty.jwt-verification-jwks")
-- initalize without cache
local ok, err = jwks.init(nil)
if not ok then
ngx.say("Error initializing jwks module: ", err)
end
-- or ...
-- initalize with local cache based on openresty shared mem dict.
-- add this in the `http` section of your nginx config: `lua_shared_dict resty_jwt_verification_cache_jwks 10m;`
-- see https://openresty-reference.readthedocs.io/en/latest/Lua_Nginx_API/#ngxshareddict
local jwks_cache_local = require("resty.jwt-verification-jwks-cache-local")
local ok, err = jwks.init(jwks_cache_local)
if not ok then
ngx.say("Error initializing jwks module: ", err)
end
You can implement your own cache and pass it in the init method instead. Here's an example how:
local my_cache = {}
---Get cached entry string for key.
---@param key string Cache key.
---@return string|nil value Return cached result as string if present, nil otherwise.
function my_cache.get(key)
-- TODO
end
---Cache data under key until expiry.
---@param key string Cache key.
---@param value string Cache value.
---@param expiry integer Cache entry expiry in seconds.
---@return boolean|nil ok true on success
---@return string|nil err nil on success, error message otherwise.
function my_cache.setex(key, value, expiry)
-- TODO
end
local ok, err = jwks.init(my_cache)
if not ok then
ngx.say("Error initializing jwks module: ", err)
end
syntax: jwks.set_http_timeouts_ms(connect, send, read)
Set HTTP client timeouts in milliseconds used for fetching JWKS.
local jwks = require("resty.jwt-verification-jwks")
jwks.enable_cache_strategy_local(5000, 5000, 5000)
syntax: jwks.set_http_ssl_verify(enabled)
Enable/disable TLS verification used by HTTP client for fetching JWKS.
By default, all TLS certificates are verified. If the JWKS endpoint is using self-signed certificates, either add the respective root CA to the OS certs store or disable certificates verification with this endpoint (it's unsafe).
local jwks = require("resty.jwt-verification-jwks")
jwks.set_http_ssl_verify(false)
syntax: jwks.set_http_ssl_verify(enabled)
Change the default cache TTL. Default value is 12 hours.
Note: The cache ttl can only be used when the jwks module has been initialized with a cache. See how to enable caching.
local jwks = require("resty.jwt-verification-jwks")
jwks.set_cache_ttl(2 * 3600) -- 2h
syntax: payload, err = jwks.fetch_jwks(endpoint)
Manually fetch JWKS from HTTP endpoint; the returned payload, in case of success, is the HTTP response body as string: No check is performed whatsoever whether the payload contains JWKS or something else.
If a caching strategy has been enabled, the endpoint will try to fetch it from the cache first. After a cache miss and successful JWKS retrieval via HTTP, the cache will be updated with the result.
local jwks = require("resty.jwt-verification-jwks")
payload, err = jwks.fetch_jwks("https://www.googleapis.com/oauth2/v3/certs")
if payload == nil then
print("failed fetching JWKS: ", err)
return
end
print(payload) -- '{"keys":[{"alg":"RS256","e":"AQAB","kid":"882503a5fd56e9f734dfba5c50d7bf48db284ae9","kty":"RSA","n":"woRUr445_ODXrFeynz5L208aJkABOKQHEzbfGM_V1ijkYZWZKY0PXKPP_wRKcE4C6OyjDNd5gHh3dF5QsVhVDZCfR9QjTf94o4asngrHzdOcfQ0pZIvzu_vzaVG82VGLM-2rKQp8uz06A6TbUzbIv9wQ8wQpYDIdujNkLqL22Mkb2drPxm9Y9I05PmVdkkvAbu4Q_KRJWxykOigHp-hVBmpYS2P3xuX56gM7ZRcXXJKKUfrGel4nDhSIAAD1wBNcVVgKbb0TYfZmVpRSCji_b6JHjqYhYjUasdotYJzWl7quAFsN_X_4j-cHZ30OS81j--OiIxWpL11y1kcbE0u-Dw","use":"sig"},{"n":"m7GlcF1ExRB4braT7sDnZvlY3wpqX9krkVRqcVA-m43FWFYBtuSpd-lc0EV8R8TO180y0tSgJc7hviI1IBJQlNa7XkjVGhY0ZFUp5rTpC45QbA9Smo4CLa5HQIf-69rkkovjFNMuDQvNiYCgRPLyRjmQbN2uHl4fU3hhf5qFqKTKo7eLCZiEMjrOkTXziA7xJJigUGe-ab8U-AXNH1fnCbejzHEIxL0eUG_4r4xddImOxETDO5T65AQCeqs7vtYos2xq5SLFuaUsithRQ-IMm3OlcVhMjBYt6uvGS6IdMjKon4wThCxEqAEXg0nahiGjnQCW176SNF152__TOjQVwQ","alg":"RS256","kty":"RSA","use":"sig","kid":"8e8fc8e556f7a76d08d35829d6f90ae2e12cfd0d","e":"AQAB"}]}'
syntax: jwt, err = jwks.verify_jwt_with_jwks(jwt_token, jwks_endpoint, jwt_options?)
Given a jwt_token as a string, verify its signature with JWKS provided by the HTTP service found at jwks_endpoint.
On success, the decrypted/verified JWT is returned as a lua table, otherwise nil and an error are returned.
The optional parameter jwt_options
can be passed to configure the token validator when calling jwt.verify
after having successfully fetched the JWKS. See jwt.verify respective docs for more info about which options
can be passed.
Note: As of this document, It's possible to verify any JWS using this method but no JWE: It's in the planned features to implement section.
local jwks = require("resty.jwt-verification-jwks")
jwt, err = jwks.verify_jwt_with_jwks("<MY_JWT>", "http://myservice:8888/.well-known/jwks.json", nil)
if jwt == nil then
print("failed verifying jwt: ", err)
return
end
print(jwt.header.alg)
print(tostring(jwt.payload))
- RFC 7515 JSON Web Signature (JWS)
- RFC 7516 JSON Web Encryption (JWE)
- RFC 7517 JSON Web Key (JWK)
- RFC 7518 JSON Web Algorithms (JWA)
- RFC 7519 JSON Web Token (JWT)
- RFC 7520 Examples of Protecting Content Using JSON Object Signing and Encryption (JOSE)
Install test suit:
sudo cpan Test::Nginx
Install openresty: see https://openresty.org/en/linux-packages.html
export PATH=/usr/local/openresty/nginx/sbin:$PATH
prove -r t