Skip to content

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

  1. Your app requests a code with POST …/auth/v1/otp and { email } (supabase-js signInWithOtp({ email })). AnvilBase always returns an empty 200 — 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"}'
  2. AnvilBase emails the user a 6-digit code, rendered from the project’s email_otp template and sent via the project’s SMTP config.

  3. Your app collects the code and exchanges it for a session with POST …/auth/v1/verify using type: "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).

Email OTPMagic link
What the user getsa 6-digit code to typea link to click
Completion endpointPOST …/auth/v1/verify with { email, token, type:'email' }the emailed …/auth/v1/verify?type=magiclink&token=… URL
Best fornative / mobile apps, no deep-link plumbingweb apps where clicking a link is natural
Email templateemail_otpmagic_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.