RackJwtVerifier
A Rack middleware that authenticates requests with JSON Web Tokens (JWT) signed by an external identity provider (SSO / OIDC) or by your own internal services.
It verifies the signature against the provider's public key — from a JWKS endpoint, a PEM URL or a static key — or, as an explicit opt-in for internal services, against a shared HMAC secret; validates the standard claims, caches key material, handles key rotation, and puts the verified claims into the Rack environment for your application. Works with any Rack application, including Ruby on Rails.
It is the verifying half of a pair: jwt_auth_client issues the tokens on the calling side. See Pairing with jwt_auth_client.
Features
- Key sources:
jwks_url(what Keycloak, Auth0, Okta, Entra ID, Cognito, Google… publish),public_key_url(a PEM public key or X.509 certificate), a staticpublic_key, or — opt-in — ashared_secretfor HMAC. - Algorithms:
RS256by default;RS*,PS*,ES*viaalgorithms:;HS256/HS384/HS512only withshared_secret. HS and RS/ES can never be enabled together, andnoneis always refused — at boot. - Claim validation:
exp(required),nbf, andiss/aud— which you must configure, or opt out of explicitly. - Scopes:
require_scopes:answers403with an RFC 6750insufficient_scopechallenge;RackJwtVerifier::Scopesreads scopes for per-route checks. - Replay protection: optional
replay_cache:remembers eachjtiuntil the token expires. - Key rotation: unknown
kidor a signature mismatch triggers a rate-limited refetch, so rotated keys are picked up without waiting for the cache TTL. - Caching: in-process by default; plug in any
read/writecache store (e.g.ActiveSupport::Cache) so all workers share one fetched key. - Hardened fetch: HTTPS enforced, redirects never followed, 5 s timeouts, 64 KB size cap, bad responses never cached.
- Rack 2 and 3,
Rack::Lint-clean responses, RFC 6750WWW-Authenticatechallenges, optional JSON error bodies, path skipping, custom error hook.
Installation
gem 'rack-jwt-verifier'
$ bundle install
Requires Ruby 3.1+, Rack 2.2 or 3.x, and ruby-jwt 2.8+ or 3.x.
Quick start
# config/application.rb (Rails) or config.ru (plain Rack)
Rails.application.config.middleware.use RackJwtVerifier::Middleware,
jwks_url: "https://sso.example.com/.well-known/jwks.json",
decode_options: {
iss: "https://sso.example.com", # who must have issued the token
aud: "my-api" # who the token must be for
}
iss and aud are required: the middleware refuses to boot without them (see Claim validation).
Then, in your application:
claims = request.env["rack_jwt_verifier.payload"] # Hash of claims, or nil if no token was sent
claims["sub"] # the subject
RackJwtVerifier::Scopes.from(claims) # => ["read:invoices", ...]
Key sources
Exactly one of these must be given.
| Option | What it serves | Notes |
|---|---|---|
:jwks_url |
A JSON Web Key Set ({"keys":[…]}) |
Tokens are matched by their kid header. Recommended — this is what nearly every provider publishes. |
:public_key_url |
A single PEM public key or an X.509 certificate | Fine for providers that expose one key. |
:public_key |
A PEM string, certificate PEM, or OpenSSL::PKey |
No network access at all. Handy for ENV["SSO_PUBLIC_KEY"]. |
:shared_secret |
An HMAC secret: a String, or { env: "VAR_NAME" } |
Enables HS256/HS384/HS512. For a small trusted set of internal services only — see below. |
URLs must be https://. Pass allow_insecure_http: true to permit http:// in development only — over plaintext HTTP an attacker on the network path can swap the key and mint arbitrary tokens.
# Static key from the environment
use RackJwtVerifier::Middleware,
public_key: ENV.fetch("SSO_PUBLIC_KEY"),
decode_options: { iss: "https://sso.example.com", aud: "my-api" }
# EC keys need the algorithm list widened
use RackJwtVerifier::Middleware,
jwks_url: "https://sso.example.com/.well-known/jwks.json",
algorithms: %w[RS256 ES256],
decode_options: { iss: "https://sso.example.com", aud: "my-api" }
Tokens without a kid header are rejected when using jwks_url. If your provider does not set one, add decode_options: { allow_nil_kid: true } — the first key in the set is then used.
EdDSA is not in scope: ruby-jwt 2.x only provides it through the native rbnacl gem and 3.x through jwt-eddsa; neither is a dependency here.
Shared secret (HMAC) — internal services only
Asymmetric keys are the default and the recommended path: the verifier only ever holds a public key, so a compromised API cannot mint tokens. A shared secret is different — every service holding it can mint tokens for every audience. Use shared_secret for a small, trusted set of internal services that you control on both ends (this is what jwt_auth_client 0.2.x needs), keep the set of holders small, and move to asymmetric keys when the issuer supports them.
use RackJwtVerifier::Middleware,
shared_secret: { env: "JWT_SERVICE_SECRET" }, # or the String itself: ENV.fetch("JWT_SERVICE_SECRET")
algorithms: ["HS256"], # default with shared_secret; HS384/HS512 also allowed
decode_options: { iss: "main_app_sso", aud: "billing_api" }
Guardrails, all enforced at boot with a RackJwtVerifier::ConfigurationError:
- The secret must be at least 32 / 48 / 64 bytes for HS256 / HS384 / HS512 (RFC 7518 §3.2) — the same rule
jwt_auth_clientapplies when signing.openssl rand -hex 32produces a 64-byte hex string that satisfies all three. shared_secretcannot be combined withpublic_key,public_key_urlorjwks_url, andHS*cannot appear inalgorithmswithout it, nor next toRS*/ES*/PS*. This closes the classic algorithm-confusion attack in which an attacker signs a token withHS256using the public key as the secret.noneis refused everywhere, in any spelling, including throughdecode_options.
Caching and key rotation
Fetched key material is cached for cache_ttl seconds (default 300). The default store is a per-process InProcessCache; for multi-process or multi-host deployments pass any object with the standard cache-store interface — read(key) and write(key, value, expires_in: seconds) — so the provider is contacted once per TTL rather than once per worker:
# config/initializers/rack_jwt_verifier.rb
Rails.application.config.middleware.use RackJwtVerifier::Middleware,
jwks_url: ENV.fetch("SSO_JWKS_URL"),
cache_store: Rails.cache, # any ActiveSupport::Cache::Store works
decode_options: { iss: ENV.fetch("SSO_ISSUER"), aud: "my-api" }
The object must be a cache store, not a raw client — a redis-rb connection does not respond to read/write; wrap it in ActiveSupport::Cache::RedisCacheStore. Cache keys are namespaced (rack_jwt_verifier:jwks:<url digest>) so several middlewares can share one store.
Rotation. When a token's kid is not in the cached set (JWKS), or its signature does not verify against the cached key (PEM), the middleware refetches once and retries. Refetches are rate-limited to one per refetch_interval seconds (default 60) so a flood of forged tokens cannot become a flood of requests to your provider.
Within one process only one thread performs a fetch on a cold cache; the others wait for it.
Options
Middleware
| Option | Default | Purpose |
|---|---|---|
:require_token |
false |
true: a request with no Bearer token gets a 401. false: it is passed through with no payload set, and your application decides. |
:skip |
[] |
Paths that bypass the middleware entirely: exact strings ("/health"), regexps (%r{\A/public/}), or callables on the env (->(env) { env["REQUEST_METHOD"] == "OPTIONS" }). Matched against SCRIPT_NAME + PATH_INFO. |
:env_key |
"rack_jwt_verifier.payload" |
Rack env key that receives the verified claims. |
:json_errors |
false |
Render 401/503 bodies as {"error": "...", "error_description": "..."} with content-type: application/json. |
:on_error |
— | ->(env, reason, exception) { … } returning a Rack response to use instead of the default, or nil to keep the default. reason is :missing_token, :invalid_token or :key_unavailable. |
:logger |
env["rack.logger"] |
Rejected tokens log at warn, key-fetch failures at error. Falls back to the request's rack.logger (Rails.logger in Rails), then to silence. |
:require_scopes |
[] |
Scopes every token must grant. A token lacking one gets 403 with WWW-Authenticate: Bearer error="insufficient_scope", scope="…". Implies require_token: true. See Scopes. |
:require_iss_aud |
true |
Refuse to boot unless decode_options sets both iss and aud. false logs a warning instead. |
:replay_cache |
off | true to record each jti in cache_store, or a cache store to record it in. See Replay protection. |
Key fetching
| Option | Default | Purpose |
|---|---|---|
:algorithms |
["RS256"] (["HS256"] with shared_secret) |
Accepted signing algorithms. HS* only with shared_secret; never mixed. |
:cache_store |
InProcessCache.new |
See Caching above. |
:cache_ttl |
300 |
Seconds to cache fetched key material. |
:refetch_interval |
60 |
Minimum seconds between rotation-triggered refetches. |
:http_timeout |
5 |
Open and read timeout, in seconds, for the key fetch. |
:allow_insecure_http |
false |
Permit a plain http:// URL. Development only. |
Responses over 64 KB are refused — a PEM key is under 1 KB and a JWKS a few KB.
Claim validation (:decode_options)
Everything here is handed to JWT.decode.
| Option | Default | Purpose |
|---|---|---|
:iss |
— | Required. The issuer the token must carry (a String, or an Array of accepted issuers). |
:aud |
— | Required. The audience the token must carry. |
:sub |
— | The subject the token must carry. |
:leeway |
60 |
Clock-skew tolerance, in seconds, for exp and nbf. 0 for strict timing. |
:required_claims |
["exp"] |
Claims that must be present. ruby-jwt only checks exp when it is there; requiring it means a token without an expiry is refused rather than valid forever. |
:verify_expiration |
true |
Check exp. Leave on. |
:verify_not_before |
true |
Check nbf. Leave on. |
:allow_nil_kid |
false |
JWKS only: accept tokens without a kid. |
The
jwtgem only checksiss/aud/subwhen the matchingverify_iss/verify_aud/verify_subflag is alsotrue. This middleware switches the flag on automatically whenever you supply a value, soiss: "…"really is enforced. An explicitverify_iss: falsenext toiss:is respected.
iss and aud are required. A key proves who signed a token, not who it was for — and a shared secret proves even less. Middleware.new raises ConfigurationError when either is missing. If you genuinely cannot check them (a provider that sets no aud, say), pass require_iss_aud: false; the middleware then logs a warning at boot instead.
Scopes
The verified claims are in env["rack_jwt_verifier.payload"], sub included. Scopes are read from a scopes claim (an Array of Strings, what jwt_auth_client emits) or, failing that, an OAuth-style space-delimited scope String.
Require scopes globally on the middleware:
use RackJwtVerifier::Middleware, jwks_url: "…", decode_options: { … },
require_scopes: ["read:invoices"]
A token lacking any of them is refused with 403 and
WWW-Authenticate: Bearer error="insufficient_scope", error_description="Token lacks required scope(s): read:invoices", scope="read:invoices"
(reason is :insufficient_scope for on_error and JSON bodies; the hook receives a RackJwtVerifier::InsufficientScopeError with #required and #missing). require_scopes implies require_token: true.
For per-route checks, use the helper in your application:
claims = request.env["rack_jwt_verifier.payload"]
RackJwtVerifier::Scopes.from(claims) # => ["read:invoices"]
RackJwtVerifier::Scopes.include?(claims, "write:invoices") # => false
RackJwtVerifier::Scopes.missing(claims, %w[read:x write:x]) # => ["write:x"]
Replay protection
Off by default. With replay_cache: each verified token's jti is remembered until the token's exp (plus leeway), and a second presentation is refused with 401 invalid_token. Tokens without a jti are refused too.
use RackJwtVerifier::Middleware, …,
cache_store: Rails.cache,
replay_cache: true # record jtis in cache_store …
# replay_cache: Rails.cache # … or in a store of their own
- The store is the same
read/writeabstraction ascache_store.trueusescache_store(or a freshInProcessCachewhen none was given). An in-process store only detects replays within one worker; use a shared store (Rails.cacheon Redis/Memcached) for real protection. jwt_auth_clientmints a freshjtiper request by default (token_reuse_seconds = 0), so replay protection works out of the box; withtoken_reuse_seconds > 0on the issuer, do not enable it here.- The check runs only after every other check passed, so an expired or mis-signed token cannot "burn" a
jti. - A replay store that raises makes the request fail closed:
503withRetry-After, reason:replay_cache_unavailable. You asked for the guarantee; skipping it silently would be worse than a retryable error. (The key cache, by contrast, fails open — a fetch still verifies the signature.) - Keys are
rack_jwt_verifier:jti:<sha256 of jti>, written withunless_exist: trueso stores that support it (ActiveSupport's do) close the check-then-write window.
Request flow
- If the path matches a
:skiprule, the request goes straight through. - The token is read from
Authorization: Bearer <token>(scheme matched case-insensitively).- No token: passed through with no payload — or
401withWWW-Authenticate: Bearerifrequire_token: true.
- No token: passed through with no payload — or
- Key material is read from the cache, or fetched on a miss.
- Signature and claims are verified.
- Success: the claims are stored in
env["rack_jwt_verifier.payload"]and the request continues. - Invalid token (expired, bad signature, wrong issuer/audience, unknown
kid, replayedjti):401withWWW-Authenticate: Bearer error="invalid_token", error_description="…". - Valid token without a required scope:
403withWWW-Authenticate: Bearer error="insufficient_scope", scope="…". - Key material unavailable (endpoint down, timeout, not a key), or the replay store down:
503withRetry-After: 5— the failure is on our side, not the client's.
- Success: the claims are stored in
JWT::DecodeErrors raised by your application are never intercepted; only the middleware's own verification step is guarded.
Errors
| Class | Raised when |
|---|---|
RackJwtVerifier::ConfigurationError |
An unusable option set, at boot (Middleware.new / Verifier.new): no or several key sources, HS* mixed with RS*, a short secret, missing iss/aud, an http:// URL, … |
RackJwtVerifier::KeyFetchError |
Key material could not be fetched or parsed. The middleware turns it into a 503. |
RackJwtVerifier::ReplayCacheError |
The replay store could not be read or written. 503. |
RackJwtVerifier::ReplayedTokenError (< JWT::InvalidJtiError) |
A jti was presented twice. 401. |
RackJwtVerifier::InsufficientScopeError |
Handed to on_error for a 403; carries #required and #missing. |
All but ReplayedTokenError inherit from RackJwtVerifier::Error. Token verification failures are ruby-jwt's JWT::DecodeError family.
Pairing with jwt_auth_client
jwt_auth_client is the issuing half: it mints { iss, sub, aud, scopes, iat, nbf, exp, jti, … } tokens and sends them as Bearer tokens from one internal service to another. Today (0.2.x) it signs with HS256/HS384/HS512 and a shared secret; asymmetric signing with a kid is planned for its 0.3.0, at which point the verifier side below becomes a jwks_url.
Issuer (config/initializers/jwt_auth_client.rb in the calling service):
JwtAuthClient.configure do |config|
config.shared_secret = ENV.fetch("JWT_SERVICE_SECRET") # >= 32 bytes; openssl rand -hex 32
config.issuer = "main_app_sso"
config.algorithm = "HS256"
config.service_urls = { billing_api: "https://billing.internal" }
end
BILLING = JwtAuthClient::HttpClient.call(user_id: "etl", target_service: :billing_api, scopes: ["read:invoices"])
Verifier (in the receiving service, billing_api):
Rails.application.config.middleware.use RackJwtVerifier::Middleware,
shared_secret: { env: "JWT_SERVICE_SECRET" }, # the same secret
algorithms: ["HS256"], # the same algorithm
decode_options: {
iss: "main_app_sso", # jwt_auth_client's config.issuer
aud: "billing_api" # the target_service the caller names
},
require_scopes: ["read:invoices"], # optional
replay_cache: true, cache_store: Rails.cache # optional
The claims then arrive as env["rack_jwt_verifier.payload"]: sub is the caller's user_id, scopes its scopes, and any custom claims from Issuable#jwt_claims (user_id, email, …) come through unchanged. The interop spec exercises exactly this round trip against the real gem.
Security considerations
issandaudare mandatory for a reason: without them any token signed by the provider — for any application — is accepted. Opt out only when you cannot check them, and know what that means.- Use HTTPS for key URLs.
allow_insecure_httpexists for local development only. Redirects are never followed. - Prefer
jwks_url. It supports multiple keys andkid-based rotation; a single PEM URL cannot express an overlap period. - Keep
leewaysmall. 60 s covers real clock skew; larger values extend the life of expired tokens. - Prefer asymmetric algorithms. With a public key the verifier cannot mint tokens; with a shared secret it can.
HS*andRS*/ES*are never accepted together — the middleware refuses to boot if you try — so a public key can never be reinterpreted as an HMAC secret. - Replay protection needs a shared store to mean anything across workers or hosts.
Design notes
A few choices that are not obvious from the code:
- The gem is
rack-jwt-verifier, the require pathrack_jwt_verifier. The hyphenated name was published first and is what users already depend on, so it stays;lib/rack-jwt-verifier.rbis a one-line shim so Bundler's auto-require works. - Options are a positional hash, not keyword arguments.
middleware.use Klass, hashhands the hash over positionally, so a keyword signature would break Rails users on Ruby 3. iss/audswitch theirverify_*flag on automatically. ruby-jwt ignores an expected claim value unless the flag is set; requiring users to pass both is how the 0.1.0 README ended up recommending a configuration that enforced nothing.- Key-fetch failures answer 503, not 401. The client did nothing wrong; a 401 would make it discard a valid token and re-authenticate.
- Rotation refetches are rate-limited (
refetch_interval) so a stream of forged tokens cannot be turned into a stream of requests to the provider. - The HMAC key-length rule lives here, not in ruby-jwt. ruby-jwt 2.x verifies with any non-empty String; the RFC 7518 minimum is enforced by this gem so both halves of the pair agree.
- The replay store fails closed, the key cache fails open. A key-cache miss still ends in a verified signature; a skipped replay check silently drops a guarantee the operator asked for.
JwtHelperis deprecated (removal in 0.4.0). It was a second token issuer inside the verifier, signing withoutiss/aud/nbf/jti. Issue withjwt_auth_client; in test suites sign withJWT.encode(seespec/support/token_factory.rb). It warns once per process;RACK_JWT_VERIFIER_SILENCE_DEPRECATIONS=1silences it.
Development
RSpec, WebMock (no real network in tests), Timecop and RuboCop. CI runs the suite on Ruby 3.1–4.0 against Rack 2, Rack 3 and ruby-jwt 2/3.
$ bundle install
$ bundle exec rake # specs + rubocop
$ bundle exec rspec
$ BUNDLE_GEMFILE=gemfiles/rack_2.gemfile bundle exec rspec # the Rack 2 leg
$ BUNDLE_GEMFILE=gemfiles/jwt_3.gemfile bundle exec rspec # the ruby-jwt 3 leg
The interop specs need a checkout of jwt_auth_client next to this repository (or JWT_AUTH_CLIENT_PATH=/path/to/it); they skip otherwise.
License
MIT — see LICENSE.md.