Email OTP
Email-OTP sign-in lets a user authenticate by entering a one-time 6-digit code emailed to them — no password and no link to click. It’s the code variant of passwordless auth; the link variant is Magic link.
The data-plane auth API is a GoTrue-compatible facade mounted under
/auth/v1, so the two endpoints below live at
/v1/auth/<project_id>/auth/v1/<endpoint> — the exact paths supabase-js
calls.
The flow
-
Your app requests a code with
POST …/auth/v1/otpand{ email }(supabase-jssignInWithOtp({ email })). AnvilBase always returns an empty200— it never reveals whether the email exists.Terminal window curl -X POST "http://localhost:39001/v1/auth/<project_id>/auth/v1/otp" \-H "apikey: $ANON_KEY" -H "Content-Type: application/json" \-d '{"email":"ada@example.com"}' -
AnvilBase emails the user a 6-digit code, rendered from the project’s
email_otptemplate and sent via the project’s SMTP config. -
Your app collects the code and exchanges it for a session with
POST …/auth/v1/verifyusingtype: "email":Terminal window curl -X POST "http://localhost:39001/v1/auth/<project_id>/auth/v1/verify" \-H "apikey: $ANON_KEY" -H "Content-Type: application/json" \-d '{"email":"ada@example.com","token":"123456","type":"email"}'A valid code returns a full project session (a JWT pair):
{"user": { "id": "…", "email": "ada@example.com", "...": "…" },"access_token": "<jwt>","token_type": "bearer","expires_in": 3600,"expires_at": 1781233600,"refresh_token": "<jwt>"}
SDK example
// Request the code.await db.auth.signInWithOtp({ email: "ada@example.com" })
// …user reads the code from their inbox, then exchange it for a session:const { data, error } = await db.auth.verifyOtp({ email: "ada@example.com", token: "123456", type: "email",})The same methods exist on every SDK (sign_in_with_otp / verify_otp in
Python/Rust/Elixir, SignInWithOtp / VerifyOtp in Go, signInWithOtp /
verifyOtp in Kotlin/Swift).
OTP vs magic link
| Email OTP | Magic link | |
|---|---|---|
| What the user gets | a 6-digit code to type | a link to click |
| Completion endpoint | POST …/auth/v1/verify with { email, token, type:'email' } | the emailed …/auth/v1/verify?type=magiclink&token=… URL |
| Best for | native / mobile apps, no deep-link plumbing | web apps where clicking a link is natural |
| Email template | email_otp | magic_link |
Both reuse the same /verify endpoint that completes the sign-up
email-confirmation flow.
The same 6-digit OTP mechanism backs four flows: email verification (sign-up
confirmation), passwordless sign-in, password recovery
(type: "recovery"), and email-change confirmation (type: "email_change")
— see Users & Sessions.
Customise the email
Edit the email_otp template under
PUT /api/v1/projects/<id>/email/templates/email_otp. See
Email & Templates. The template renders the 6-digit
code for the user.
Rate limits
Send endpoints are protected against abuse by two layers: a per-IP guard on the
control plane (5 sends/min per source IP) and a per-recipient hourly cap
(OTP_EMAIL_SEND_MAX, default 5, over OTP_SEND_WINDOW_SECS). Over the cap
returns 429 over_email_send_rate_limit with Retry-After — matching Supabase.
The cap is keyed only on the recipient, so it never reveals whether an account
exists. See Network Security and
Configuration.
Next: Multi-factor auth.