supabase-rb-rb
Web mode

AuthErrorMapper

Supabase::Rails::Web::AuthErrorMapper — translates Supabase::Auth::Errors::* into the gem-stable Supabase::Rails::AuthError surface with deterministic code and HTTP status.

Supabase::Rails::Web::AuthErrorMapper is the single place every Supabase::Auth::Errors::* exception from supabase-rb is translated into the gem's stable Supabase::Rails::AuthError surface. The output carries a code (one of nine constants on AuthError) and an HTTP status that controllers, the Rack middleware, and any host error-handling code can match on without inspecting upstream exception classes.

You will use this module any time you call Supabase::Auth::Client methods from custom code — sign_in_with_otp, verify_otp, update_user, reauthenticate, etc. The mapper is also called internally by the gem-shipped controllers (SessionsController, PasswordsController, OauthController, OtpController, RegistrationsController) so flash messages and JSON error bodies stay consistent. The module is documented here because (a) the dispatch is case/when against Supabase::Auth::Errors::* ancestors and ordering matters, (b) AuthApiError's status-based subdispatch is the only mapping with a numeric branch, and (c) the 503 outcomes are the ones operators most need to recognise during incidents.

# Typical use from a custom controller.
def reauthenticate
  client = Supabase::Rails::Web::AuthClientFactory.build(request)
  client.reauthenticate
  head :no_content
rescue ::Supabase::Auth::Errors::AuthError => e
  err = Supabase::Rails::Web::AuthErrorMapper.translate(e)
  Rails.logger.warn("Reauth failed: [#{err.code}] #{err.message}")
  render json: { code: err.code, message: err.message }, status: err.status
end

AuthErrorMapper.translate(err)

ReturnsVisibility
Supabase::Rails::AuthError with stable code and statusPublic, module-method.
ArgumentTypeNotes
errSupabase::Auth::Errors::AuthError or any StandardErrorThe upstream exception. Non-Supabase exceptions fall through the default branch and become AUTH_GENERIC_ERROR / 500.

translate never raises. The message is extracted via err.respond_to?(:message) ? err.message.to_s : err.to_s, so an exception with a nil message produces an AuthError with message: "" rather than blowing up.

The translation table

The case dispatch covers every documented Supabase::Auth::Errors::* class. The first match wins, so the table also shows the visiting order — leaf classes before their ancestors, since the upstream hierarchy nests some subclasses several levels deep.

Upstream supabase-rb error classAuthError::CODEHTTP statusNotes
AuthInvalidCredentialsErrorINVALID_CREDENTIALS401Wrong email/password, invalid OTP.
AuthInvalidJwtErrorINVALID_CREDENTIALS401A token-shape problem upstream (rare in :web mode — JWT verify happens inside the gem via JWT.verify).
AuthSessionMissingSESSION_MISSING401The upstream client tried to act on a session that didn't exist (e.g. reauthenticate without a current session).
AuthWeakPasswordWEAK_PASSWORD422Sign-up or password update rejected by Supabase's strength rules.
AuthPKCEErrorPKCE_ERROR400OAuth round-trip failed — verifier mismatch, missing state, etc.
AuthRetryableErrorAUTH_RETRYABLE503Upstream signalled "try again" (rate-limited, brief unavailability).
AuthApiError 4xxAUTH_API_ERRORpreserved 4xxThe upstream HTTP status flows through verbatim.
AuthApiError 5xxAUTH_UPSTREAM_ERROR503Every 5xx collapses to 503 — your app is healthy, Supabase is degraded.
AuthUnknownErrorAUTH_GENERIC_ERROR500Upstream raised something the supabase-rb hierarchy doesn't classify.
Any other StandardErrorAUTH_GENERIC_ERROR500Defensive — out-of-band errors (e.g. Timeout::Error) hit the default branch.

Why AuthApiError is split by status

Supabase::Auth::Errors::AuthApiError is a single class that wraps any non-2xx HTTP response from /auth/v1/*. The mapper sub-dispatches on err.status:

def map_api_error(message, status)
  if status.is_a?(Integer) && status >= 500
    AuthError.new(message, AuthError::AUTH_UPSTREAM_ERROR, 503)
  else
    upstream = status.is_a?(Integer) ? status : 400
    AuthError.new(message, AuthError::AUTH_API_ERROR, upstream)
  end
end
  • 4xx preserves the upstream status. A 422 Unprocessable Entity from Supabase becomes AuthError(AUTH_API_ERROR, 422) — caller error semantics are kept intact.
  • 5xx collapses to 503 Service Unavailable. The caller (your app) is fine; the dependency is degraded. Returning the literal 502/504 Supabase reported would be confusing — operators reading your app's metrics would assume your upstream returned that status.
  • Non-Integer status (defensive: if the upstream ever yields a nil status) is treated as 400.

The 5xx-to-503 collapse is also what keeps the CookieCredentialStrategy refresh path's REFRESH_UNAVAILABLE semantics consistent — :transient is the same shape regardless of whether Supabase reported 500, 502, 503, or 504.

Ordering of the case/when

The dispatch is case/when err, which uses Class#=== (i.e. is_a?). Five of the leaf classes (AuthRetryableError, AuthSessionMissing, AuthInvalidCredentialsError, AuthInvalidJwtError, AuthWeakPassword) inherit from CustomAuthError < AuthError; AuthApiError, AuthPKCEError, and AuthUnknownError inherit directly from AuthError. None of them inherit from each other, so the order within those two leaf groups is cosmetic — but the leaf-before-ancestor rule still matters because:

  • AuthApiError is the only class that needs status-based sub-dispatch — placing it before AuthError ensures it isn't swallowed by a too-broad ancestor.
  • A future upstream class added under one of these branches will need to be placed in the dispatch before its parent. The mapper is small enough that this is easy to spot in PR review, but the module docstring calls it out explicitly to keep the rule in mind.

Code constants

Supabase::Rails::AuthError defines nine code constants — eight surface through this mapper and one (INVALID_REDIRECT) is gem-side only (see RedirectValidator):

ConstantString valueProduced by
AuthError::INVALID_CREDENTIALS"INVALID_CREDENTIALS"AuthInvalidCredentialsError, AuthInvalidJwtError, and gem-internal JWT verify failures.
AuthError::SESSION_MISSING"SESSION_MISSING"AuthSessionMissing.
AuthError::WEAK_PASSWORD"WEAK_PASSWORD"AuthWeakPassword.
AuthError::PKCE_ERROR"PKCE_ERROR"AuthPKCEError.
AuthError::AUTH_RETRYABLE"AUTH_RETRYABLE"AuthRetryableError.
AuthError::AUTH_API_ERROR"AUTH_API_ERROR"AuthApiError (4xx).
AuthError::AUTH_UPSTREAM_ERROR"AUTH_UPSTREAM_ERROR"AuthApiError (5xx).
AuthError::AUTH_GENERIC_ERROR"AUTH_ERROR"AuthUnknownError and any unrecognised StandardError.
AuthError::REFRESH_UNAVAILABLE"REFRESH_UNAVAILABLE"Not produced by translate — raised directly by CookieCredentialStrategy for the cookie-refresh transient path.
AuthError::CREATE_SUPABASE_CLIENT_ERROR"CREATE_SUPABASE_CLIENT_ERROR"Not produced by translate — raised by Supabase::Rails.build_context_result on Supabase::SupabaseException.
AuthError::INVALID_REDIRECT"INVALID_REDIRECT"Not produced by translate — raised by RedirectValidator.

The AUTH_GENERIC_ERROR constant uses the string "AUTH_ERROR" (without the GENERIC_ infix) — a small naming asymmetry that matters when matching on the string in tests.

Match on the constants (e.code == AuthError::INVALID_CREDENTIALS), not the strings — the strings are stable, but the constants are clearer and let your IDE jump to definition.

Errors that don't go through the mapper

Three AuthError paths bypass translate because they are raised directly by the gem rather than by the upstream client:

ErrorRaised byWhy bypass
AuthError.invalid_credentials (manual constructor)Supabase::Rails::JWT.verify and Core.verify_credentialsThe gem fails JWT verification before talking to upstream — there's no upstream exception to translate.
AuthError.refresh_unavailableCookieCredentialStrategyThe strategy already classified the upstream error as :transient and produces the 503 directly. Sending it back through the mapper would duplicate work.
AuthError.invalid_redirect(uri)RedirectValidatorOpen-redirect rejection is a gem-side check that never sees an upstream call.
AuthError.session_missing (manual constructor)Authentication concern, when terminate_session is called without an active sessionSame — gem-side, no upstream call.

The mapper is exclusively for upstream-to-stable-AuthError translation. Matching on e.code works the same regardless of which path produced the error.

How errors surface

:web mode

Most upstream errors during sign-in / sign-up / OAuth / OTP / password-reset are caught inside the gem-shipped controllers and rendered as a flash message + redirect back to the form. The mapper is what populates the code you see logged via the [supabase.rails.sign_in_failure] code=<MAPPED_CODE> line — <MAPPED_CODE> is the string value of AuthError::CODE.

For the cookie-refresh transient path specifically, the middleware renders a JSON 503 body ({ "message": "...", "code": "REFRESH_UNAVAILABLE" }) — see CookieCredentialStrategy → Anonymous downgrade vs. JSON 503.

:api mode

The middleware translates upstream errors via the mapper and renders the JSON body:

{ "message": "Invalid credentials", "code": "INVALID_CREDENTIALS" }

with the mapped status. Clients can match on code to display a localised message.

Examples

Translating an upstream error in a custom controller

def update_password
  client = Supabase::Rails::Web::AuthClientFactory.build(request)
  client.update_user(password: params[:password])
  flash.notice = "Password updated."
  redirect_to account_path
rescue ::Supabase::Auth::Errors::AuthError => e
  err = Supabase::Rails::Web::AuthErrorMapper.translate(e)

  case err.code
  when Supabase::Rails::AuthError::WEAK_PASSWORD
    flash.alert = "Password is too weak. #{err.message}"
  when Supabase::Rails::AuthError::SESSION_MISSING
    flash.alert = "Please sign in again to change your password."
  else
    flash.alert = "Couldn't update password (#{err.code})."
  end

  redirect_to edit_account_path
end

Translating a non-Supabase error

err = Supabase::Rails::Web::AuthErrorMapper.translate(Timeout::Error.new("oops"))
err.code    # => "AUTH_ERROR"
err.status  # => 500
err.message # => "oops"

The default branch catches anything not in the upstream hierarchy and produces a 500 AUTH_GENERIC_ERROR — a safe fallback that won't accidentally surface as a 401 or a credential-shaped error to the user.

Mapping an AuthApiError 5xx to 503

upstream = ::Supabase::Auth::Errors::AuthApiError.new("Bad gateway", status: 502)
err = Supabase::Rails::Web::AuthErrorMapper.translate(upstream)
err.code   # => "AUTH_UPSTREAM_ERROR"
err.status # => 503

Your app's 502 is now a 503 — the right semantic for "we couldn't talk to Supabase" rather than "we couldn't talk to ourselves".

Mapping a 4xx through to its upstream status

upstream = ::Supabase::Auth::Errors::AuthApiError.new("Email taken", status: 422)
err = Supabase::Rails::Web::AuthErrorMapper.translate(upstream)
err.code   # => "AUTH_API_ERROR"
err.status # => 422

The 4xx is the caller's problem — preserving it lets the host UI distinguish 409 Conflict from 422 Unprocessable Entity without parsing the message.

What this module does not do

  • It does not log. Logging is the caller's responsibility (the gem-shipped controllers log a redacted [supabase.rails.sign_in_failure] line; the middleware logs through Supabase::Rails::Logging). The mapper is pure — call it, then decide how loudly to react.
  • It does not raise. A nil message, a missing status, or a non-Supabase exception all produce a valid AuthError. The mapper is safe to call from rescue blocks without further begin/rescue.
  • It does not produce gem-side error codes. INVALID_REDIRECT, REFRESH_UNAVAILABLE, and CREATE_SUPABASE_CLIENT_ERROR are raised directly by their respective modules — see the table in Errors that don't go through the mapper.
  • It does not localise messages. The message is whatever the upstream said. Hosts that want localised error text should match on err.code and look up an I18n key.

See also

On this page