Email & Password
The sign-up, sign-in, magic-link and password-reset flows every Floot project ships with, and the SMTP setup that makes them work.
Email and password is the one auth method that is on from the moment
floot create finishes. Every social provider and the second factor are layered
on top of it, so this page is the prerequisite for all of them.
Codes, not links
There are no clickable links in these emails
Sign-up confirmation, magic link and password reset all send a 6-digit code that the user types into the app. Nothing in those three emails is clickable. If you are waiting for a link to arrive, you are waiting for something that is never sent.
The three templates render the code and nothing else: look at the {token}
they each drop into a bordered box.
| Template | |
|---|---|
| Sign-up confirmation | supabase/functions/send-email/_templates/sign-up/confirm-email.tsx |
| Magic link | supabase/functions/send-email/_templates/magic-link/magic-link.tsx |
| Password reset | supabase/functions/send-email/_templates/password-reset/password-reset-email.tsx |
"Magic link" is therefore a slight misnomer here: it is a passwordless sign-in
code. The naming comes from Supabase's magiclink action type, which the kit
keeps so that verifyOTP matches what GoTrue expects.
The one exception: invites
The fourth Supabase email action, invite, is link-based. It is the only
one that carries a URL, and it is verified through a tokenHash rather than a
typed code. Sending invites is what Multi-tenancy only adds; the
verification path itself is present in every project. The invitation flow
itself is on Members & Invites.
Sign-up ends on the verification screen, via a failure
signUpWithEmailAndPassword calls signUp and then immediately tries
signInWithPassword. Because enable_confirmations = true, that second call
fails with an unverified-email error, which SignUpPage catches as
UnverifiedSignUpEmail and turns into a redirect to /email-verification.
It looks like an error path because it is one, but it is the intended one.
Password rules are stricter in the app than in the database
The Flutter validator
(lib/core/presentation/utils/validators/password.dart) defaults to 12
characters, plus at least one lowercase letter, one uppercase letter, one
digit and one of !@#$%^&*. Supabase's own floor is lower:
minimum_password_length = 8, so the app is the binding constraint. Loosen
the validator by passing different arguments to Password.dirty(...) in
sign_up_cubit.dart and pwd_reset_cubit.dart; tighten the server in
supabase/config.toml. Changing one without the other just moves where the
rejection happens.
How the email actually gets sent
Supabase does not send these emails. A send-email hook intercepts every auth email and hands it to an Edge Function, which renders the React Email template and posts it over SMTP.
[auth.hook.send_email]
enabled = true
uri = "http://host.docker.internal:54321/functions/v1/send-email"
secrets = "env(SB_SEND_EMAIL_HOOK_SECRET)"The hook signs its request with SB_SEND_EMAIL_HOOK_SECRET, and
supabase/functions/send-email/_utils/email_processor.ts verifies that
signature before doing anything, which is why [functions.send-email] sets
verify_jwt = false: the standardwebhooks signature is the authentication, not
a JWT.
No functions served, no email
The hook calls a local Edge Function. If
supabase functions serve is not running, sign-up, magic link and password
reset all fail. Keep it running in its own terminal; see Get
Started.
The SMTP environment set
Five variables live in supabase/.env.local. All five are unconditionally
required: unlike the Stripe and RevenueCat keys, none of them is gated
behind an ENABLE_* flag.
SMTP_FROM: z.string().min(1),
SMTP_HOST: z.string().min(1),
SMTP_PASSWORD: z.string().min(1),
SMTP_PORT: z.coerce.number().min(1),
SMTP_USER: z.string().min(1),env.ts parses the environment at module load and throws if anything is
missing, and every Edge Function imports it transitively. A missing SMTP
variable does not break email; it stops the whole function runtime from
booting.
A blank value counts as missing: env.ts strips empty strings before
validating, precisely so that KEY= reads as "not provided" for the optional
keys.
All four are prefilled for local development
floot create fills in the whole set for the Mailpit instance that
supabase start runs, credentials included. Mailpit does not advertise
AUTH, so nodemailer never transmits them; the placeholder values exist
only to satisfy the schema. Blank either one while wiring up a real provider
and the functions stop booting until you fill it back in.
Filled in for local development, the block reads:
SMTP_FROM="My App <noreply@acme.com>"
SMTP_HOST=host.docker.internal
SMTP_PASSWORD=mailpit
SMTP_PORT=54325
SMTP_USER=mailpithost.docker.internal rather than 127.0.0.1 because the Edge Function runs
inside a container and has to reach back out to the host. Port 54325 is
Mailpit's SMTP listener; the number you see in the supabase start banner,
54324, is its web UI.
Reading the mail
Nothing leaves your machine locally. Every email the project sends, auth codes, the password-changed notice, invitations, is caught by Mailpit at http://127.0.0.1:54324. Open it side by side with the app and copy the code out of the message.
Both ports come from supabase/config.toml:
[local_smtp]
enabled = true
port = 54324
smtp_port = 54325Moving to a real provider
For a hosted project, point the same five variables at your provider (Resend, SendGrid, Postmark, SES, anything that speaks SMTP) and set them as Edge Function secrets rather than in a file:
Replace the local values. SMTP_FROM must be a Name <email> pair on a domain
you have verified with the provider, or the provider will reject the message.
SMTP_FROM="My App <noreply@myapp.com>"
SMTP_HOST=smtp.resend.com
SMTP_PASSWORD="<your api key>"
SMTP_PORT=587
SMTP_USER=resendMind the port. supabase/functions/_shared/clients.ts decides TLS purely from
the number:
secure: env.SMTP_PORT === 465 ? true : false,465 means implicit TLS; 587 means STARTTLS, which nodemailer negotiates itself. Any other port is treated as plaintext.
Repoint the hook. uri in [auth.hook.send_email] is a host.docker.internal
address that only exists on your machine. On a hosted project the hook is
configured in the dashboard, under Authentication → Hooks, pointing at your
deployed send-email function, and SB_SEND_EMAIL_HOOK_SECRET has to match
on both sides.
The [auth.email.smtp] block stays commented out
config.toml ships a commented [auth.email.smtp] block. That is GoTrue's
own SMTP client, and this kit does not use it: the send-email hook takes
over before GoTrue would send anything, so the credentials that matter are the
SMTP_* variables the Edge Function reads. Leave the block alone unless you
remove the hook.
The settings behind these flows
Everything above is driven by two blocks in supabase/config.toml. These are
the values a generated project ships with, and the ones worth understanding
before you change any of them.
[auth], account-level policy:
| Key | Value | Why it matters |
|---|---|---|
site_url | "http://127.0.0.1:3000" | Base URL Auth uses to build links, and the first entry on the redirect allow-list. |
additional_redirect_urls | ["https://127.0.0.1:3000", "com.acme.myapp://oauth-callback"] | Anything not on this list is refused as a redirect target. See Deep Links. |
jwt_expiry | 3600 | Access tokens live an hour; refresh-token rotation is on, with a 10-second reuse window. |
enable_signup | true | Turn this off to freeze the user base without touching the app. |
enable_anonymous_sign_ins | false | No guest sessions. Nothing in the app asks for one. |
enable_manual_linking | false | Identities are read-only; see below. |
minimum_password_length | 8 | Server floor. The app enforces 12. |
password_requirements | "lower_upper_letters_digits_symbols" | Server-side character-class rule, mirrored by the Flutter validator. |
[auth.email], the email flows specifically:
| Key | Value | Why it matters |
|---|---|---|
enable_confirmations | true | The address must be confirmed before sign-in works. This is what routes a new user to /email-verification. |
otp_length | 6 | Matches the six boxes in AuthOtpVerifier. Change one and you must change the other. |
otp_expiry | 3600 | A code stays valid for an hour. After that the app shows "The code you provided is invalid or has expired. Please request a new one." |
max_frequency | "45s" | Minimum gap between two emails to the same address; see Rate limits. |
double_confirm_changes | true | An email change has to be confirmed from both the old and the new address. |
secure_password_change | false | updatePassword does not demand a recent login. Turning it on would break the reset flow, which sets the new password on a session established by the recovery code. |
Three further notifications also ride the same hook and are on by default:
[auth.email.notification.password_changed],
[auth.email.notification.mfa_factor_enrolled] and
[auth.email.notification.mfa_factor_unenrolled]. They are security notices, not
codes, and nothing in the app has to handle them.
Rate limits you will hit
Two numbers in supabase/config.toml decide how often a user can ask for an
email, and both surface in the app as the same toast: "Too many requests.
Please try again later." There is no countdown and no distinct message: a
429 from GoTrue becomes TooManySignUpRequests, TooManyPwdReq,
TooManyLoginRequests or EmailTooManyRequests, and every one of them maps to
l10n.tooManyRequests.
[auth.rate_limit]
email_sent = 60
[auth.email]
max_frequency = "45s"| Key | Value | What it means |
|---|---|---|
max_frequency | 45s | Minimum gap between two emails to the same address. This is the one you hit during development. |
email_sent | 60 | Ceiling on auth emails per hour for the whole project. This is the one you hit in a demo or a test suite. Its own comment in config.toml notes it applies once an SMTP server is configured. |
The OTP screen's own 60-second resend cooldown sits deliberately above the 45 second server gap, so the Resend button never trips the limit on its own. What trips it is re-submitting the sign-up form, or requesting a password reset two or three times in a row while wondering where the email went.
Raising these locally is a config.toml edit plus a supabase stop && supabase start. On a hosted project they live in the dashboard, under
Authentication → Rate Limits, and the hosted defaults are far stricter
than the local ones; budget for that before a launch.
Users cannot link a second sign-in method
enable_manual_linking = falseWith manual linking off, GoTrue rejects linkIdentity and unlinkIdentity
outright, which makes the account's identity list read-only for the whole
lifetime of the account. The set of providers on a user is whatever Supabase
attached automatically: password on sign-up, plus any social provider that
matched on a verified email address.
So a "Connect your Google account" button is not a UI you can simply build: it
needs enable_manual_linking = true (and the matching dashboard setting on a
hosted project) before the API calls behind it will succeed. Flipping it on is a
security decision as much as a feature one, letting a signed-in session attach
arbitrary identities to an account widens what a stolen session can do, so it
ships off.