Opinionated JWT toolkit for Ruby — secure by default, with support for
gem install philiprehberger-jwt_kit
Opinionated JWT toolkit for Ruby — secure by default, with support for encoding, validation, refresh tokens, revocation, and key rotation
Add to your Gemfile:
gem "philiprehberger-jwt_kit"
Or install directly:
gem install philiprehberger-jwt_kit
require "philiprehberger/jwt_kit"
Philiprehberger::JwtKit.configure do |c|
c.secret = "your-secret-key-at-least-32-characters"
c.issuer = "my-app"
end
token = Philiprehberger::JwtKit.encode(user_id: 42)
payload = Philiprehberger::JwtKit.decode(token)
payload["user_id"] # => 42
Philiprehberger::JwtKit.configure do |c|
c.secret = "your-secret-key" # Required — HMAC signing key
c.algorithm = :hs256 # :hs256 (default), :hs384, :hs512
c.issuer = "my-app" # Optional — sets the `iss` claim
c.expiration = 3600 # Access token TTL in seconds (default: 1 hour)
c.refresh_expiration = 86_400 * 7 # Refresh token TTL (default: 1 week)
end
token = Philiprehberger::JwtKit.encode(user_id: 42, role: "admin")
# => "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHA..."
Claims exp, iat, iss, and jti are added automatically.
payload = Philiprehberger::JwtKit.decode(token)
payload["user_id"] # => 42
payload["exp"] # => 1711036800
payload["iss"] # => "my-app"
payload["jti"] # => "a1b2c3d4-..."
Decoding validates the signature, expiration, and issuer automatically.
access_token, refresh_token = Philiprehberger::JwtKit.token_pair(user_id: 42)
The access token uses the standard expiration. The refresh token uses refresh_expiration and includes a type: "refresh" claim.
new_access_token = Philiprehberger::JwtKit.refresh(refresh_token)
Validates the refresh token, verifies it has type: "refresh", and issues a new access token with the original payload.
Philiprehberger::JwtKit.revoke(token)
Philiprehberger::JwtKit.revoked?(token) # => true
Philiprehberger::JwtKit.decode(token) # => raises RevokedToken
Revocation uses an in-memory store keyed by JTI. The store is thread-safe.
Decode a token without verifying its signature — useful for inspecting claims or determining which key to use:
result = Philiprehberger::JwtKit.peek(token)
result[:header] # => {"alg"=>"HS256", "typ"=>"JWT"}
result[:payload] # => {"user_id"=>42, "exp"=>..., "iat"=>..., "jti"=>...}
Check whether a token's exp claim is in the past without verifying the signature:
Philiprehberger::JwtKit.expired?(token) # => false
# Use to decide whether to refresh before the authoritative decode
Get the seconds remaining until the token's exp claim. Negative when expired, nil when the token is malformed or has no numeric exp. Useful for scheduling pre-emptive refreshes rather than reacting after the fact:
Philiprehberger::JwtKit.time_to_expiry(token) # => 3599
# refresh when fewer than 60 seconds remain
Philiprehberger::JwtKit.refresh(refresh_token) if Philiprehberger::JwtKit.time_to_expiry(token).to_i < 60
Philiprehberger::JwtKit.configure do |c|
c.secret = "secret"
c.audience = "my-api" # string or array of strings
end
# Tokens automatically include the `aud` claim
token = Philiprehberger::JwtKit.encode(user_id: 42)
# Decoding validates the audience matches configuration
Philiprehberger::JwtKit.decode(token) # => raises InvalidAudience if mismatch
Returns a result hash instead of raising exceptions:
result = Philiprehberger::JwtKit.validate(token)
# => { valid: true, payload: { "user_id" => 42, ... }, error: nil }
result = Philiprehberger::JwtKit.validate(expired_token)
# => { valid: false, payload: nil, error: "Token has expired" }
Configure multiple secrets with key IDs for seamless key rotation:
Philiprehberger::JwtKit.configure do |c|
c.secrets = [
{ kid: "key-2024", secret: "new-secret-key" }, # Used for signing
{ kid: "key-2023", secret: "old-secret-key" } # Still accepted for verification
]
end
# Encodes using the first secret, adds `kid` to the JWT header
token = Philiprehberger::JwtKit.encode(user_id: 42)
# Decoding reads `kid` from the header and finds the matching secret
payload = Philiprehberger::JwtKit.decode(token)
Remove old revocation entries to keep memory usage bounded:
# Remove entries older than 1 hour
Philiprehberger::JwtKit.revocation_store.cleanup!(max_age: 3600)
Replace the default in-memory store with any object that responds to #revoke, #revoked?, #clear, and #size:
# Example: plug in a Redis-backed store
Philiprehberger::JwtKit.revocation_store = MyRedisRevocationStore.new
Hook into encode, decode, refresh, and revoke without monkey-patching. Useful for audit logging, metrics, and tracing:
Philiprehberger::JwtKit.configure do |c|
c.secret = 'your-secret-key'
c.on_encode do |token, payload|
Metrics.increment('jwt.encoded', tags: { iss: payload['iss'] })
end
c.on_decode do |payload|
Audit.log('jwt.decoded', user_id: payload['user_id'], jti: payload['jti'])
end
c.on_refresh do |new_token|
Metrics.increment('jwt.refreshed')
end
c.on_revoke do |jti|
Audit.log('jwt.revoked', jti: jti)
end
end
Callbacks fire only after a successful operation. Exceptions raised inside a callback are swallowed so they cannot break the calling JWT operation.
| Method | Description |
|---|---|
JwtKit.configure { |c| ... } | Configure secret, algorithm, issuer, and expiration |
JwtKit.configuration | Returns the current configuration |
JwtKit.reset_configuration! | Resets configuration to defaults |
JwtKit.encode(payload) | Encodes a payload into a signed JWT token |
JwtKit.decode(token) | Decodes and validates a JWT token |
JwtKit.validate(token) | Validates a token, returns result hash instead of raising |
JwtKit.token_pair(payload) | Generates an access/refresh token pair |
JwtKit.refresh(refresh_token) | Issues a new access token from a refresh token |
JwtKit.revoke(token) | Revokes a token by its JTI |
JwtKit.revoked?(token) | Checks if a token has been revoked |
JwtKit.peek(token) | Decode header and payload without signature verification |
JwtKit.expired?(token) | Check exp claim without verifying the signature |
JwtKit.time_to_expiry(token) | Seconds remaining until exp; negative when expired, nil when unknown |
JwtKit.revocation_store= | Set a custom revocation store |
MemoryStore#cleanup!(max_age:) | Remove revocation entries older than max_age seconds |
Configuration#on_encode { |token, payload| ... } | Register a callback fired after a successful encode |
Configuration#on_decode { |payload| ... } | Register a callback fired after a successful decode |
Configuration#on_refresh { |new_token| ... } | Register a callback fired after a successful refresh |
Configuration#on_revoke { |jti| ... } | Register a callback fired after a successful revoke |
bundle install
bundle exec rspec
bundle exec rubocop
If you find this project useful: