supabase-rb-rb
Controllers

OtpController

Supabase::Rails::OtpController — passwordless OTP / magic-link sign-in.

Supabase::Rails::OtpController handles passwordless sign-in via email magic links or SMS one-time codes. The install generator writes a top-level OtpController that inherits from it.

Under the hood, create wraps the supabase-rb auth.sign_in_with_otp call (via supabase_sign_in_with_otp); verify wraps auth.verify_otp (via supabase_verify_otp); the optional resend override wraps auth.resend (via supabase_resend).

# app/controllers/otp_controller.rb (written by `bin/rails generate supabase:install`)
class OtpController < Supabase::Rails::OtpController
end

The flow has two steps: request a code/link, then verify the code. The verify action handles both the "user pasted the code" (POST) and "user clicked the magic link" (GET) cases on the same URL.

Source

# app/controllers/supabase/rails/otp_controller.rb (in the gem)
class OtpController < BaseController
  allow_unauthenticated_access only: %i[new create verify]

  def new; end

  def create
    result = supabase_sign_in_with_otp(
      email: params[:email],
      phone: params[:phone],
    )
    if result.success?
      redirect_to verify_otp_index_path,
                  notice: I18n.t("supabase.rails.otp.sent")
    else
      flash.now[:alert] = result.error.message
      render :new, status: :unprocessable_entity
    end
  end

  def verify
    return unless request.post?

    result = supabase_verify_otp(
      token: params[:token],
      type: params[:type] || "email",
      email: params[:email],
      phone: params[:phone],
    )
    if result.success?
      redirect_to after_authentication_url,
                  notice: I18n.t("supabase.rails.otp.verified")
    else
      flash.now[:alert] = result.error.message
      render :verify, status: :unprocessable_entity
    end
  end
end

Routes

HelperVerbURLAction
new_otp_pathGET/otp/newnew
otp_index_pathPOST/otpcreate
verify_otp_index_pathGET, POST/otp/verifyverify

Mounted by:

resources :otp, only: %i[new create] do
  collection do
    match :verify, via: %i[get post], as: :verify
  end
end

inside supabase_authentication_routes. The verify collection route responds to both GET and POST so the magic-link flow (Supabase Auth GETs the URL after the user clicks the link) and the OTP-code flow (the user pastes the code into the form, POST) hit the same action.

Actions

new

Renders the "Send me a code" form at /otp/new. Action body is empty. Allowed without authentication.

The shipped view at app/views/supabase/rails/otp/new.html.erb submits to otp_index_path with one field (email).

create

Triggers OTP / magic-link delivery and redirects to the verify page. Allowed without authentication.

Params

ParamSourceNotes
emailPOST bodyEither email or phone required.
phonePOST bodyEither email or phone required. E.164 format expected by Supabase Auth.

Additional keyword options accepted by supabase_sign_in_with_otpemail_redirect_to:, should_create_user:, data:, channel: ("sms" / "whatsapp"), captcha_token: — are not wired up by the default create body. Override to expose them.

Outcome dispatch

BranchRedirect / renderStatusFlash
Successredirect_to verify_otp_index_path302notice: I18n.t("supabase.rails.otp.sent")
Failure — 4xxrender :new422flash.now[:alert] = result.error.message
Failure — 5xxrender :new422flash.now[:alert] = result.error.message (AUTH_UPSTREAM_ERROR)

The success branch fires whether or not the identifier exists in Supabase Auth (prevents user enumeration). No session is created at this step — the user must verify the code first.

verify

The "enter your code" handler. Allowed without authentication. The action body uses return unless request.post? so GETs render the verify form (no token-from-URL exchange happens server-side from the default body).

On GET — renders otp/verify.html.erb (the form lets the user paste the emailed code or it gets pre-filled from URL params by the shipped view).

On POST — exchanges the supplied token for a session.

Params (POST)

ParamSourceNotes
tokenPOST bodyRequired. The OTP code or magic-link token.
typePOST bodyDefaults to "email" if omitted. Accepted values include "email", "sms", "magiclink", "recovery", "invite", "signup".
emailPOST bodyOptional. Required for email OTP.
phonePOST bodyOptional. Required for SMS OTP.

Outcome dispatch (POST)

BranchRedirect / renderStatusFlash
Successredirect_to after_authentication_url302notice: I18n.t("supabase.rails.otp.verified")
Failure — invalid / expired coderender :verify422flash.now[:alert] = result.error.message (INVALID_CREDENTIALS)
Failure — 5xxrender :verify422flash.now[:alert] = result.error.message (AUTH_UPSTREAM_ERROR)

On success supabase_verify_otp internally calls start_new_session_for so the encrypted cookie is written and Current.user / Current.session are populated before the redirect runs. The post-verify redirect target comes from the after_authentication_url hook.

Magic-link GET requests fall through to render the form

The default verify body's return unless request.post? means a magic-link GET to /otp/verify?token=...&email=... renders the verify form — the user must click a "Verify" button on that form to POST and exchange the token. To auto-verify on GET (better UX for magic links), override the action to call supabase_verify_otp regardless of the HTTP verb.

allow_unauthenticated_access

allow_unauthenticated_access only: %i[new create verify]

All three actions are exempt. Verification is by definition a pre-authentication step.

Hookable callbacks

HookDefaultWhere to override
after_authentication_urlstored_location_for_redirect || root_urlapp/controllers/concerns/authentication.rb (preferred) or the host's OtpController

Override patterns

Auto-verify on magic-link GET. Drop the return unless request.post? so magic-link GETs exchange the token without forcing the user through a form click:

class OtpController < Supabase::Rails::OtpController
  def verify
    result = supabase_verify_otp(
      token: params[:token],
      type: params[:type] || "email",
      email: params[:email],
      phone: params[:phone],
    )
    if result.success?
      redirect_to after_authentication_url, notice: t(".verified")
    else
      flash.now[:alert] = result.error.message
      render :verify, status: :unprocessable_entity
    end
  end
end

The flow is now: Supabase Auth email contains <your-app>/otp/verify?token=...&email=...&type=magiclink → user clicks → host issues an authenticated session and redirects to the dashboard, no form interaction needed.

SMS-channel OTP. Override create to pass phone: and channel: so supabase_sign_in_with_otp routes through SMS / WhatsApp:

class OtpController < Supabase::Rails::OtpController
  def create
    result = supabase_sign_in_with_otp(
      phone: params[:phone],
      channel: params[:channel] || "sms",
    )
    if result.success?
      redirect_to verify_otp_index_path(phone: params[:phone], type: "sms"),
                  notice: t(".sent_sms")
    else
      flash.now[:alert] = result.error.message
      render :new, status: :unprocessable_entity
    end
  end
end

Note the redirect_to verify_otp_index_path(phone: ..., type: "sms") — the SMS verify call needs the phone and type: "sms" to make it back into the POST body, and the easiest way is to pass them as URL params and have the verify form echo them as hidden fields.

Resend. Use supabase_resend to re-trigger delivery without re-entering the email:

class OtpController < Supabase::Rails::OtpController
  def resend
    result = supabase_resend(type: "email", email: params[:email])
    if result.success?
      redirect_to verify_otp_index_path, notice: t(".resent")
    else
      flash.now[:alert] = result.error.message
      redirect_to verify_otp_index_path
    end
  end
end

You will also need to mount a resend route — add it inside the resources :otp block in config/routes.rb:

resources :otp, only: %i[] do
  collection do
    post :resend
  end
end

(after supabase_authentication_routes, so the gem's route table comes first).

Replacing with a from-scratch controller — point the host subclass at Supabase::Rails::BaseController directly. The supabase_sign_in_with_otp, supabase_verify_otp, and supabase_resend helpers are still available from the included Authentication concern.

See also

On this page