supabase-rb-rb
Authentication

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"]    # => 1730000000

Supabase::Rails::JWT.verify(token, env:)

ReturnsRaises
{ user_claims: UserClaims, jwt_claims: Hash } on successSupabase::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

AlgorithmWhen Supabase uses it
RS256Default for new projects (asymmetric, RSA).
ES256Asymmetric, elliptic-curve. Configurable per project.
HS256Legacy 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 a String — every Supabase user JWT carries the user's UUID in sub. A missing or non-string sub raises AuthError.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.jwksWhat verify does
Hash (e.g. {"keys" => [...]})Used directly. No HTTP fetch.
URI::HTTPS instanceFetched via Net::HTTP.get_response, cached (see Cache behaviour).
URI::HTTP instance with a loopback hostSame as HTTPS (loopback exemption: localhost, *.localhost, 127.0.0.0/8, [::1]).
URI::HTTP instance with a non-loopback hostRefused — raises AuthError.invalid_credentials.
nilRaises 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):

  1. config.supabase.env[:jwks] if config.supabase.env is set and has a :jwks key. The value can be a Hash (inline) or a parsed URI.
  2. 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.
  3. 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.

ConstantValueMeaning
CACHE_TTL_SECONDS600A successfully-fetched JWKS is reused for 10 minutes from the moment of fetch.
MISS_COOLDOWN_SECONDS30After a failed fetch (network error, non-2xx, malformed JSON), subsequent verify calls re-raise immediately for 30 seconds without retrying the HTTP request.
LEEWAY_SECONDS30Per-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

  1. First call — cache miss, HTTP GET against the URL, parses the body. On 200 with {"keys": [...]}, stores { value: jwks, fetched_at: monotonic_now }.
  2. Subsequent calls within 600 s — return the cached value without touching the network.
  3. Calls after 600 s — treat the entry as stale, refetch. On success the slot is rewritten with a new fetched_at; on failure the stale value is discarded and the cooldown timer starts.
  4. Failure — the slot stores last_miss_at. Within 30 s, every subsequent call raises AuthError.invalid_credentials without 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!

ReturnsVisibility
The empty cache HashPublic, 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.

ConditioncodestatusMessage
token is nil or emptyINVALID_CREDENTIALS401"Invalid credentials"
env.jwks is nilAUTH_ERROR500"JWKS not configured for user auth mode"
JWKS URL is plain-HTTP non-loopbackINVALID_CREDENTIALS401"Invalid credentials"
JWKS fetch failed and cooldown is activeINVALID_CREDENTIALS401"Invalid credentials"
JWKS HTTP response is not 2xxINVALID_CREDENTIALS401"Invalid credentials"
JWKS response body is not a Hash with a "keys" arrayINVALID_CREDENTIALS401"Invalid credentials"
Signature verification failedINVALID_CREDENTIALS401"Invalid credentials"
Algorithm not in ALGORITHMS allowlistINVALID_CREDENTIALS401"Invalid credentials"
Token expired beyond the 30 s leewayINVALID_CREDENTIALS401"Invalid credentials"
Verified payload is not a Hash or sub is missing/non-stringINVALID_CREDENTIALS401"Invalid credentials"
Any other StandardError from JWT.decodeINVALID_CREDENTIALS401"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
end

Env.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

On this page