JWT verification
Supabase::Rails::JWT.verify — the access-token verifier behind every authenticated request, with JWKS resolution, in-memory caching, and the AuthError shape it raises.
Supabase::Rails::JWT is the gem's access-token verifier. Every request that arrives with a Bearer token (:api mode) or carries a Supabase session cookie (:web mode) flows through Supabase::Rails::JWT.verify before Current.user is populated — the call is what turns an opaque token string into the user_claims and jwt_claims the rest of the gem hands back to your controllers.
You almost never call JWT.verify directly. The middleware runs it for you and surfaces the result via supabase_context; your controllers read Current.user instead. The module is documented here because: (a) the errors it raises propagate to your controllers and need an error-handling story, (b) the JWKS cache it maintains has visible TTL/cooldown behaviour during key rotation, and (c) host apps writing custom Rack middleware or out-of-band token verification (background jobs replaying webhooks, etc.) need the same primitive.
# Verify an arbitrary token outside the request cycle (e.g. background job).
env = Supabase::Rails::Env.resolve
claims = Supabase::Rails::JWT.verify(token, env: env)
claims[:user_claims].id # => "f47ac10b-58cc-4372-a567-0e02b2c3d479"
claims[:user_claims].email # => "alice@example.com"
claims[:jwt_claims]["aud"] # => "authenticated"
claims[:jwt_claims]["exp"] # => 1730000000Supabase::Rails::JWT.verify(token, env:)
| Returns | Raises |
|---|---|
{ user_claims: UserClaims, jwt_claims: Hash } on success | Supabase::Rails::AuthError on every failure (see Errors raised) |
Validates a Supabase access token against the project's JWKS and returns the verified claims. env is a Supabase::Rails::SupabaseEnv (the value Supabase::Rails::Env.resolve returns) — the verifier reads only env.jwks from it, ignoring url / publishable_keys / secret_keys. Passing env: explicitly lets you verify against a different project's JWKS without mutating process-wide ENV.
Algorithms
| Algorithm | When Supabase uses it |
|---|---|
RS256 | Default for new projects (asymmetric, RSA). |
ES256 | Asymmetric, elliptic-curve. Configurable per project. |
HS256 | Legacy symmetric signing on older projects. |
Other algorithms (none, RS512, etc.) are rejected by the underlying ruby-jwt decoder before any claim is read — the algorithm allowlist is ALGORITHMS = %w[RS256 ES256 HS256].freeze, and the decoder is invoked with algorithms: set to that frozen array.
Time skew
LEEWAY_SECONDS = 30 is passed to JWT.decode, so a token's exp claim is treated as valid for an extra 30 seconds beyond its stated expiry, and iat / nbf claims are accepted up to 30 seconds in the future. This absorbs clock skew between your Rails servers and Supabase Auth without rejecting otherwise-valid sessions.
Required claims
After successful signature verification, the verifier additionally requires:
- The payload is a
Hash— opaque scalar tokens are rejected. payload["sub"]is aString— every Supabase user JWT carries the user's UUID insub. A missing or non-stringsubraisesAuthError.invalid_credentials.
All other claims (aud, email, role, app_metadata, user_metadata, iat, exp, iss) are passed through unmodified into the returned jwt_claims hash.
JWKS resolution
env.jwks is the value Env.resolve derived from one of the two JWT-related environment variables (or from a config.supabase.env override). The verifier resolves it once per call:
Shape of env.jwks | What verify does |
|---|---|
Hash (e.g. {"keys" => [...]}) | Used directly. No HTTP fetch. |
URI::HTTPS instance | Fetched via Net::HTTP.get_response, cached (see Cache behaviour). |
URI::HTTP instance with a loopback host | Same as HTTPS (loopback exemption: localhost, *.localhost, 127.0.0.0/8, [::1]). |
URI::HTTP instance with a non-loopback host | Refused — raises AuthError.invalid_credentials. |
nil | Raises AuthError(message: "JWKS not configured for user auth mode", code: AUTH_ERROR, status: 500). |
The HTTP loopback exemption mirrors Supabase::Rails::Env.loopback_host? so local supabase start (which serves JWKS over plain HTTP) works without forcing TLS, while production must use HTTPS. Misconfiguring SUPABASE_JWKS_URL to a plain-HTTP non-loopback URL is treated like a missing key, not as a transparent fallback — the request fails closed.
Configuring JWKS
Env.resolve reads (in priority order):
config.supabase.env[:jwks]ifconfig.supabase.envis set and has a:jwkskey. The value can be aHash(inline) or a parsedURI.ENV["SUPABASE_JWKS"]— inline JSON, either{"keys":[…]}or a raw[…]array. The array form is normalised to the{"keys":[…]}shape before being handed to the verifier.ENV["SUPABASE_JWKS_URL"]— must parse as a URL and pass the HTTPS-or-loopback check.
When all three are absent (or the URL is rejected), env.jwks is nil and the verifier raises the "JWKS not configured" AuthError on the first authenticated request — the gem fails closed at request time, not at boot. See configuration → Environment variables for the full table.
Cache behaviour
When env.jwks is a URL, the verifier maintains a process-wide in-memory cache keyed by the URL string. The cache is guarded by a single Mutex so concurrent requests cannot trigger duplicate HTTP fetches.
| Constant | Value | Meaning |
|---|---|---|
CACHE_TTL_SECONDS | 600 | A successfully-fetched JWKS is reused for 10 minutes from the moment of fetch. |
MISS_COOLDOWN_SECONDS | 30 | After a failed fetch (network error, non-2xx, malformed JSON), subsequent verify calls re-raise immediately for 30 seconds without retrying the HTTP request. |
LEEWAY_SECONDS | 30 | Per-token clock-skew tolerance (independent of the cache). |
Process.clock_gettime(Process::CLOCK_MONOTONIC) is used for both age calculations so wall-clock adjustments (NTP step, container time-sync) cannot retroactively extend or shorten cached entries.
Cache lifecycle
- First call — cache miss, HTTP
GETagainst the URL, parses the body. On200with{"keys": [...]}, stores{ value: jwks, fetched_at: monotonic_now }. - Subsequent calls within 600 s — return the cached
valuewithout touching the network. - Calls after 600 s — treat the entry as stale, refetch. On success the slot is rewritten with a new
fetched_at; on failure the stalevalueis discarded and the cooldown timer starts. - Failure — the slot stores
last_miss_at. Within 30 s, every subsequent call raisesAuthError.invalid_credentialswithout hitting the network. Once 30 s have elapsed, the next call retries.
The cooldown matters during incidents: if your project's JWKS endpoint is returning 5xx, your app does not hammer it once per request. Authenticated requests still fail (correctly — the gem cannot verify tokens without a JWKS), but at most one fetch per 30 s reaches Supabase.
Cache layout
@cache = {
"https://abcd1234.supabase.co/auth/v1/.well-known/jwks.json" => {
value: { "keys" => [...] },
fetched_at: 1234567890.123, # monotonic seconds
last_miss_at: nil
}
}A successful refetch clears last_miss_at; a failure leaves the previous value in place but sets last_miss_at. The mutex ensures that an incoming request that observes a fresh value returns it without checking last_miss_at.
Supabase::Rails::JWT._reset_cache!
| Returns | Visibility |
|---|---|
The empty cache Hash | Public, but underscore-prefixed — test- and operator-only. |
Clears the entire JWKS cache. Intended for two scenarios:
# 1. RSpec — between examples that stub different JWKS endpoints.
RSpec.configure do |config|
config.before(:each) { Supabase::Rails::JWT._reset_cache! }
end
# 2. Operator — force a refetch after rotating Supabase's signing key out of band.
Supabase::Rails::JWT._reset_cache!In production you can wait 10 minutes for natural TTL expiry instead — the underscore-prefix is a hint that the method is intentionally not part of the request-path API.
Process-local cache only
The cache is a per-process Hash. On Puma with multiple workers, the first authenticated request to each worker fetches the JWKS independently. There is no Redis or shared store — the design assumes the JWKS endpoint can handle one fetch every 10 minutes per worker, which Supabase's CDN-fronted .well-known/jwks.json handles easily.
Errors raised
Supabase::Rails::JWT.verify raises Supabase::Rails::AuthError for every failure path. The code/status surface is small on purpose — verification either succeeds or fails as a credential rejection.
| Condition | code | status | Message |
|---|---|---|---|
token is nil or empty | INVALID_CREDENTIALS | 401 | "Invalid credentials" |
env.jwks is nil | AUTH_ERROR | 500 | "JWKS not configured for user auth mode" |
| JWKS URL is plain-HTTP non-loopback | INVALID_CREDENTIALS | 401 | "Invalid credentials" |
| JWKS fetch failed and cooldown is active | INVALID_CREDENTIALS | 401 | "Invalid credentials" |
| JWKS HTTP response is not 2xx | INVALID_CREDENTIALS | 401 | "Invalid credentials" |
JWKS response body is not a Hash with a "keys" array | INVALID_CREDENTIALS | 401 | "Invalid credentials" |
| Signature verification failed | INVALID_CREDENTIALS | 401 | "Invalid credentials" |
Algorithm not in ALGORITHMS allowlist | INVALID_CREDENTIALS | 401 | "Invalid credentials" |
| Token expired beyond the 30 s leeway | INVALID_CREDENTIALS | 401 | "Invalid credentials" |
Verified payload is not a Hash or sub is missing/non-string | INVALID_CREDENTIALS | 401 | "Invalid credentials" |
Any other StandardError from JWT.decode | INVALID_CREDENTIALS | 401 | "Invalid credentials" |
The collapsing of every credential-path failure into a single INVALID_CREDENTIALS is intentional: it prevents user-enumeration via timing or message variation. The one exception is the JWKS-misconfiguration case (status: 500), which is an operator error and surfaces verbatim so it is debuggable from the logs.
How errors surface to your controllers
In :api mode, the middleware catches the AuthError, calls Supabase::Rails::Logging.log(:warn, ...), and returns a JSON error body with the error's status:
{ "message": "Invalid credentials", "code": "INVALID_CREDENTIALS" }In :web mode, the CookieCredentialStrategy treats a failed verify as an anonymous context — the cookie is cleared and the request continues without a Current.user. The Authentication concern's require_authentication before-action then redirects to new_session_path. The token never reaches your controller.
The :web mode flow is why JWKS failures during a Supabase outage manifest as users being asked to sign in again rather than as a 5xx page — the gem fails closed on every authenticated request, and :web mode's failure mode is "anonymous", not "5xx".
Returned claims
{
user_claims: Supabase::Rails::UserClaims.new(
id: "f47ac10b-58cc-4372-a567-0e02b2c3d479",
role: "authenticated",
email: "alice@example.com",
app_metadata: { "provider" => "email" },
user_metadata: { "name" => "Alice" }
),
jwt_claims: {
"aud" => "authenticated",
"exp" => 1730000000,
"iat" => 1729996400,
"iss" => "https://abcd1234.supabase.co/auth/v1",
"sub" => "f47ac10b-58cc-4372-a567-0e02b2c3d479",
"email" => "alice@example.com",
"phone" => "",
"app_metadata" => { "provider" => "email", "providers" => ["email"] },
"user_metadata" => { "name" => "Alice" },
"role" => "authenticated",
"aal" => "aal1",
"amr" => [{ "method" => "password", "timestamp" => 1729996400 }],
"session_id" => "8e6c..."
}
}user_claims is a Supabase::Rails::UserClaims Struct (defined in lib/supabase/rails/core.rb) — a thin per-field projection of the JWT payload for callers that prefer a typed accessor over hash-key lookups. The five fields it carries are the ones the gem itself reads in downstream code.
jwt_claims is the verified payload Hash, exactly as returned by JWT.decode after signature verification. Use it when you need a claim UserClaims does not project (e.g. aud, session_id, aal).
Downstream code uses both: SupabaseContext exposes the full jwt_claims as ctx.jwt_claims, and Supabase::Rails::User.from_claims(jwt_claims) (the Supabase::Rails::User value object) is what populates Current.user when config.supabase.user_model is unset.
Calling verify outside a request
The verifier is module-method and stateless apart from the JWKS cache, so it is safe to call from a background job, a one-off script, or a custom Rack middleware. Resolve the env yourself:
# app/jobs/process_webhook_job.rb
class ProcessWebhookJob < ApplicationJob
def perform(token, payload)
env = Supabase::Rails::Env.resolve
claims = Supabase::Rails::JWT.verify(token, env: env)
user_id = claims[:user_claims].id
User.find(user_id).process_webhook(payload)
rescue Supabase::Rails::AuthError => e
Rails.logger.warn("Webhook auth rejected: [#{e.code}] #{e.message}")
raise # let Sidekiq drop the job
end
endEnv.resolve is cheap (reads ENV + parses JWKS once) and the JWKS cache is shared with the request path — verifying out-of-band reuses any already-fetched keys.
See also
- Authentication — the concern that consumes
verify's output viasupabase_context. - Session store — the encrypted cookie that carries the access token in
:webmode. web-mode/cookie-credential-strategy— the per-request strategy that callsverifyand refreshes near-expired tokens.- Configuration → Environment variables —
SUPABASE_JWKS/SUPABASE_JWKS_URLreference.
Authentication
The Supabase::Rails::Authentication concern, the generated app/controllers/concerns/authentication.rb, the Current model, and the supabase_context request object.
Session store
Supabase::Rails::SessionStore — the encrypted-cookie wrapper used in :web mode, the surrounding Supabase::Rails::Middleware that reads it on every request, and the cookie attributes you can configure.