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
endThe 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
endRoutes
| Helper | Verb | URL | Action |
|---|---|---|---|
new_otp_path | GET | /otp/new | new |
otp_index_path | POST | /otp | create |
verify_otp_index_path | GET, POST | /otp/verify | verify |
Mounted by:
resources :otp, only: %i[new create] do
collection do
match :verify, via: %i[get post], as: :verify
end
endinside 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
| Param | Source | Notes |
|---|---|---|
email | POST body | Either email or phone required. |
phone | POST body | Either email or phone required. E.164 format expected by Supabase Auth. |
Additional keyword options accepted by supabase_sign_in_with_otp — email_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
| Branch | Redirect / render | Status | Flash |
|---|---|---|---|
| Success | redirect_to verify_otp_index_path | 302 | notice: I18n.t("supabase.rails.otp.sent") |
| Failure — 4xx | render :new | 422 | flash.now[:alert] = result.error.message |
| Failure — 5xx | render :new | 422 | flash.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)
| Param | Source | Notes |
|---|---|---|
token | POST body | Required. The OTP code or magic-link token. |
type | POST body | Defaults to "email" if omitted. Accepted values include "email", "sms", "magiclink", "recovery", "invite", "signup". |
email | POST body | Optional. Required for email OTP. |
phone | POST body | Optional. Required for SMS OTP. |
Outcome dispatch (POST)
| Branch | Redirect / render | Status | Flash |
|---|---|---|---|
| Success | redirect_to after_authentication_url | 302 | notice: I18n.t("supabase.rails.otp.verified") |
| Failure — invalid / expired code | render :verify | 422 | flash.now[:alert] = result.error.message (INVALID_CREDENTIALS) |
| Failure — 5xx | render :verify | 422 | flash.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
| Hook | Default | Where to override |
|---|---|---|
after_authentication_url | stored_location_for_redirect || root_url | app/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
endThe 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
endNote 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
endYou 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
- Controllers overview — the full route table and the override pattern.
BaseController— the common parent class.Authenticationconcern —supabase_sign_in_with_otp,supabase_verify_otp,supabase_resend,start_new_session_for.OauthController— provider sign-in (different two-leg flow).supabase:viewsgenerator — copyotp/new.html.erb+otp/verify.html.erbinto the host app.- supabase-rb:
auth.sign_in_with_otp,auth.verify_otp, andauth.resend— the underlying Ruby methods these actions delegate to.