verikloak
A lightweight Rack middleware for verifying Keycloak JWT access tokens via OpenID Connect.
Verikloak is a plug-and-play solution for Ruby (especially Rails API) apps that need to validate incoming Bearer
tokens issued by Keycloak. It uses OpenID Connect Discovery and JWKs to fetch the public keys and verify JWT signatures securely.
Features
- OpenID Connect Discovery (
.well-known/openid-configuration
) - JWKs auto-fetching with in-memory caching and ETag support
- RS256 JWT verification using
kid
-
aud
,iss
,exp
,nbf
claim validation - Rails/Rack middleware support
- Faraday-based customizable HTTP layer
Installation
Add this line to your application's Gemfile
:
gem "verikloak"
Then install:
bundle install
Usage
Rails (API mode)
Add the middleware in config/application.rb
:
config.middleware.use Verikloak::Middleware,
discovery_url: "https://keycloak.example.com/realms/myrealm/.well-known/openid-configuration",
audience: "your-client-id",
skip_paths: ['/skip_path']
Handling Authentication Failures
When you use the Rack middleware, authentication failures are automatically converted into JSON error responses (for example, 401
for token issues, 503
for JWKs/discovery errors). In most cases you do not need to add custom rescue_from
handlers in Rails controllers.
If you use Verikloak components directly (bypassing the Rack middleware) or prefer centralized error handling, rescue from the base class Verikloak::Error
. You can also match subclasses such as Verikloak::TokenDecoderError
, Verikloak::DiscoveryError
, or Verikloak::JwksCacheError
depending on your needs:
class ApplicationController < ActionController::API
rescue_from Verikloak::Error do |e|
status =
case e
when Verikloak::TokenDecoderError
:unauthorized
when Verikloak::DiscoveryError, Verikloak::JwksCacheError
:service_unavailable
else
:unauthorized
end
render json: { error: e.class.name, message: e.message }, status: status
end
end
This ensures that even if you bypass the middleware, clients still receive structured error responses.
Note: When the Rack middleware is enabled, it already renders JSON error responses. The
rescue_from
example above is only necessary if you bypass the middleware or want custom behavior.
Error Hierarchy
All Verikloak errors inherit from Verikloak::Error
:
-
Verikloak::TokenDecoderError
– token parsing/verification (401 Unauthorized
) -
Verikloak::DiscoveryError
– OIDC discovery fetch/parse (503 Service Unavailable
) -
Verikloak::JwksCacheError
– JWKs fetch/parse/cache (503 Service Unavailable
) -
Verikloak::MiddlewareError
– header/infra issues surfaced by the middleware (usually401
, sometimes503
)
Recommended: use environment variables in production
config.middleware.use Verikloak::Middleware,
discovery_url: ENV.fetch("DISCOVERY_URL"),
audience: ENV.fetch("CLIENT_ID"),
skip_paths: ['/', '/health', '/public/*', '/rails/*']
This makes the configuration secure and flexible across environments.
Advanced middleware options
Verikloak::Middleware
exposes a few optional knobs that help integrate with
different Rack stacks:
-
token_env_key
(default:"verikloak.token"
) — where the raw JWT is stored in the Rack env -
user_env_key
(default:"verikloak.user"
) — where decoded claims are stored -
realm
(default:"verikloak"
) — value used in theWWW-Authenticate
header for 401 responses -
logger
— an object responding toerror
(and optionallydebug
) that receives unexpected 500-level failures
config.middleware.use Verikloak::Middleware,
discovery_url: ENV.fetch("DISCOVERY_URL"),
audience: ENV.fetch("CLIENT_ID"),
token_env_key: "rack.session.token",
user_env_key: "rack.session.claims",
realm: "my-api",
logger: Rails.logger
Accessing claims in controllers
Once the middleware is enabled, Verikloak adds the decoded token and raw JWT to the Rack environment.
You can access them in any Rails controller:
class Api::V1::NotesController < ApplicationController
def index
user_claims = request.env["verikloak.user"] # Hash of decoded Keycloak JWT claims
token = request.env["verikloak.token"] # Raw JWT token string
# Example: use claims for authorization or logging
render json: { sub: user_claims["sub"], email: user_claims["email"] }
end
end
Standalone Rack app
# config.ru example for a standalone Rack app
require "verikloak"
use Verikloak::Middleware,
discovery_url: "https://keycloak.example.com/realms/myrealm/.well-known/openid-configuration",
audience: "my-client-id"
run ->(env) {
user = env["verikloak.user"] # Decoded JWT claims hash (if token is valid)
[200, { "Content-Type" => "application/json" }, [user.to_json]]
}
How It Works
- Extracts the
Authorization: Bearer <token>
header - Fetches the OIDC discovery document (only once or when expired)
- Downloads JWKs public keys from the provided
jwks_uri
- Matches the
kid
from JWT header to select the right JWK - Decodes and verifies the JWT using
RS256
- Validates the following claims:
-
aud
(audience) -
iss
(issuer) -
exp
(expiration) -
nbf
(not before)
-
- Makes the decoded payload available in
env["verikloak.user"]
Error Responses
Verikloak returns JSON error responses in a consistent format with structured error codes. The HTTP status code reflects the nature of the error: 401 for client-side authentication issues, 503 for server-side discovery/JWKs errors, and 500 for unexpected internal errors.
Common HTTP Responses
-
401 Unauthorized
: The access token is missing, invalid, expired, or otherwise not valid. -
503 Service Unavailable
: Discovery or JWKs fetch/parsing failed (server-side issue). -
500 Internal Server Error
: An unexpected error occurred.
Representative Examples
{
"error": "invalid_token",
"message": "The access token is missing or invalid"
}
{
"error": "expired_token",
"message": "The access token has expired"
}
{
"error": "jwks_fetch_failed",
"message": "Failed to fetch JWKs"
}
{
"error": "jwks_parse_failed",
"message": "Failed to parse JWKs"
}
{
"error": "discovery_metadata_fetch_failed",
"message": "Failed to fetch OIDC discovery document"
}
{
"error": "discovery_metadata_invalid",
"message": "Failed to parse OIDC discovery document"
}
Error Types
Error Code | HTTP Status | Description |
---|---|---|
invalid_token |
401 Unauthorized | The token is missing, malformed, or invalid |
expired_token |
401 Unauthorized | The token has expired |
missing_authorization_header |
401 Unauthorized | The Authorization header is missing |
invalid_authorization_header |
401 Unauthorized | The Authorization header format is invalid |
unsupported_algorithm |
401 Unauthorized | The token’s signing algorithm is not supported |
invalid_signature |
401 Unauthorized | The token signature could not be verified |
invalid_issuer |
401 Unauthorized | Invalid iss claim |
invalid_audience |
401 Unauthorized | Invalid aud claim |
not_yet_valid |
401 Unauthorized | The token is not yet valid (nbf in the future) |
jwks_fetch_failed |
503 Service Unavailable | Failed to fetch JWKs |
jwks_parse_failed |
503 Service Unavailable | Failed to parse JWKs |
jwks_cache_miss |
503 Service Unavailable | JWKs cache is empty (e.g., 304 Not Modified without prior cache) |
discovery_metadata_fetch_failed |
503 Service Unavailable | Failed to fetch OIDC discovery document |
discovery_metadata_invalid |
503 Service Unavailable | Failed to parse OIDC discovery document |
discovery_redirect_error |
503 Service Unavailable | Discovery response was a redirect without a valid Location header |
internal_server_error |
500 Internal Server Error | Unexpected internal error (catch-all) |
Note: The
decode_with_public_key
method ensures consistent error codes for all JWT verification failures.
It may raiseinvalid_signature
,unsupported_algorithm
,expired_token
,invalid_issuer
,invalid_audience
, ornot_yet_valid
depending on the verification outcome.
For a full list of error cases and detailed explanations, please see the ERRORS.md file.
Configuration Options
Key | Required | Description |
---|---|---|
discovery_url |
Yes | Full URL to your realm's OIDC discovery doc |
audience |
Yes | Your client ID (checked against aud ). Accepts a String or callable returning a String/Array per request. |
skip_paths |
No | Array of paths or wildcards to skip authentication, e.g. ['/', '/health', '/public/*'] . Note: Regex patterns are not supported. |
discovery |
No | Inject custom Discovery instance (advanced/testing) |
jwks_cache |
No | Inject custom JwksCache instance (advanced/testing) |
leeway |
No | Clock skew tolerance (seconds) applied during JWT verification. Defaults to TokenDecoder::DEFAULT_LEEWAY . |
token_verify_options |
No | Hash of advanced JWT verification options passed through to TokenDecoder. For example: { verify_iat: false, leeway: 10, algorithms: ["RS256"] } . If both leeway: and token_verify_options[:leeway] are set, the latter takes precedence. |
decoder_cache_limit |
No | Maximum number of TokenDecoder instances retained per middleware. Defaults to 128 . Set to 0 to disable caching or nil for an unlimited cache. |
connection |
No | Inject a Faraday::Connection used for both Discovery and JWKs fetches. Defaults to a safe connection with timeouts and retries. |
Option: skip_paths
Plain paths are exact-match only, while /*
at the end enables prefix matching.
skip_paths
lets you specify paths (or wildcard patterns) where authentication should be skipped.
For example:
skip_paths: ['/', '/health', '/rails/*', '/public/src']
-
'/'
matches only the root path. -
'/health'
matches only/health
(for subpaths, use'/health/*'
). -
'/rails/*'
matches/rails
itself as well as/rails/foo
,/rails/foo/bar
, etc. -
'/public/src'
matches/public/src
, but does not match/public
, any subpath like/public/src/html
or other siblings like/public/css
.
Paths not matched by any skip_paths
entry will require a valid JWT.
Note: Regex patterns are not supported. Only literal paths and *
wildcards are allowed.
Internally, *
expands to match nested paths, so patterns like /rails/*
are valid. This differs from regex — for example, '/rails'
alone matches only /rails
, while '/rails/*'
covers both /rails
and deeper subpaths.
Option: audience
The audience
option may be either a static String or any callable object (Proc, lambda, object responding to #call
). When a callable is provided it receives the Rack env
(either as a positional argument or keyword env:
) and can return a different audience for each request. This is useful when a single gateway serves multiple downstream clients:
Verikloak::Middleware.new(app,
discovery_url: ENV['DISCOVERY_URL'],
audience: ->(env) {
env['PATH_INFO'].start_with?('/admin') ? 'admin-client-id' : 'public-client-id'
}
)
The callable may also return an Array of audiences when a route is valid for multiple clients.
Customizing Faraday for Discovery and JWKs
Both Discovery
and JwksCache
accept a Faraday::Connection
.
Verikloak ships with a helper you can re-use anywhere:
connection = Verikloak::HTTP.default_connection
The default connection enables retries (via faraday-retry
) for idempotent GET requests and applies conservative timeouts (5s request / 2s open). If you need to add extra middleware, adapters, or instrumentation, you can build on top of the defaults:
connection = Faraday.new(request: { timeout: 10 }) do |f|
f.request :retry, Verikloak::HTTP::RETRY_OPTIONS
f.response :logger
f.adapter Faraday.default_adapter
end
config.middleware.use Verikloak::Middleware,
discovery_url: ENV["DISCOVERY_URL"],
audience: ENV["CLIENT_ID"],
connection: connection
# Or pass the connection through to a shared JwksCache instance
jwks_cache = Verikloak::JwksCache.new(
jwks_uri: "https://example.com/realms/myrealm/protocol/openid-connect/certs",
connection: connection
)
This makes it easy to keep HTTP settings consistent across discovery, JWK refreshes, and any other Verikloak components you wire together.
Customizing token verification (leeway and options)
You can fine-tune how JWTs are verified by setting leeway
or providing advanced options via token_verify_options
. For example:
config.middleware.use Verikloak::Middleware,
discovery_url: ENV["DISCOVERY_URL"],
audience: ENV["CLIENT_ID"],
leeway: 30, # allow 30s clock skew
token_verify_options: {
verify_iat: true,
verify_expiration: true,
verify_not_before: true,
# algorithms: ["RS256"] # override algorithms if needed
# leeway: 10 # this overrides the top-level leeway
}
-
leeway:
sets the default skew tolerance in seconds. -
token_verify_options:
is passed directly to TokenDecoder (and ultimately toJWT.decode
). - If both are set,
token_verify_options[:leeway]
takes precedence.
Decoder cache & performance
Internally, Verikloak caches TokenDecoder
instances per JWKs fetch to avoid reinitializing
them on every request. The cache behaves like an LRU with a configurable size (decoder_cache_limit
)
so long-running processes do not accumulate decoders for one-off audiences. When the underlying
JWK set rotates, the middleware now clears the cache to drop decoders that point at stale keys.
Set decoder_cache_limit
to 0
if you prefer to construct a fresh decoder every time, or nil
when you want the cache to grow without bounds (e.g., in short-lived jobs).
Sharing caches across verikloak gems
When combining verikloak
with companion gems (such as verikloak-rails
, verikloak-bff
, etc.),
reusing infrastructure objects avoids redundant HTTP calls:
connection = Verikloak::HTTP.default_connection
jwks_cache = Verikloak::JwksCache.new(jwks_uri: ENV['JWKS_URI'], connection: connection)
use Verikloak::Middleware,
discovery_url: ENV['DISCOVERY_URL'],
audience: ->(env) { env['verikloak.audience'] },
connection: connection,
jwks_cache: jwks_cache
# Rails initializer or service object can now reuse `connection` and `jwks_cache`
# when configuring other verikloak-* gems.
Sharing the Faraday connection keeps retry/time-out policies consistent, while a single JwksCache
ensures all middleware layers refresh keys in unison.
Architecture
Verikloak consists of modular components, each with a focused responsibility:
Component | Responsibility | Layer |
---|---|---|
Middleware |
Rack-compatible entry point for token validation | Rack layer |
Discovery |
Fetches OIDC discovery metadata (.well-known ) |
Network layer |
HTTP |
Provides shared Faraday connection with retries/timeouts | Network layer |
JwksCache |
Fetches & caches JWKs public keys (with ETag) | Cache layer |
TokenDecoder |
Decodes and verifies JWTs (signature, exp, nbf, iss, aud) | Crypto layer |
Errors |
Centralized error hierarchy | Core layer |
This separation enables better testing, modular reuse, and flexibility.
Development (for contributors)
Clone and install dependencies:
git clone https://github.com/taiyaky/verikloak.git
cd verikloak
bundle install
See Testing below to run specs and RuboCop. For releasing, see Publishing.
Testing
All pull requests and pushes are automatically tested with RSpec and RuboCop via GitHub Actions. See the CI badge at the top for current build status.
To run the test suite locally:
docker compose run --rm dev rspec
docker compose run --rm dev rubocop
Contributing
Bug reports and pull requests are welcome! Please see CONTRIBUTING.md for details.
Security
If you find a security vulnerability, please follow the instructions in SECURITY.md.
License
This project is licensed under the MIT License.
Publishing (for maintainers)
Gem release instructions are documented separately in MAINTAINERS.md.
Changelog
See CHANGELOG.md for release history.