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 email action. The magic link
screen verifies the typed code as OtpType.email, which accepts any emailed
sign-in code. That matters for an account that never confirmed its email: asking
for a magic link sends it the sign-up confirmation email instead, and that code
signs it in from the same screen.
Asking for a magic link with an email that has no account shows an error toast on the login page instead of a code screen waiting for an email that never comes.
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 makes a single signUp call. Because
enable_confirmations = true, the response carries no session, so the method
throws UnverifiedSignUpEmail, which SignUpPage turns into a redirect to
/email-verification. It looks like an error path because it is one, but it
is the intended one. An email that already has a confirmed account fails with
AccountAlreadyExists instead, and the page tells the user to log in or reset
their password.
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 real mail, point the same five variables at your provider (Resend,
SendGrid, Postmark, SES, anything that speaks SMTP). You can try them locally in
supabase/.env.local. On your deployed backend they are function secrets like
the others, never in a file the app bundles: set with supabase secrets set on
Supabase Cloud (see Mail), and as environment
on the functions service when self-hosted (see Function
secrets).
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. Where the functions run decides which ports are open: on Supabase Cloud use 465, and on your own server whichever of 465 and 587 your host lets out.
Repoint the hook. uri in [auth.hook.send_email] is a host.docker.internal
address that only exists on your machine. Your deployed backend's hook points
at your deployed send-email function over https://, and its secret is the
same v1,whsec_ value as the function secret SB_SEND_EMAIL_HOOK_SECRET. On
Supabase Cloud it is set in the dashboard, under Authentication →
Configuration → Auth Hooks (see The Send Email
hook); self-hosted, as
GOTRUE_HOOK_SEND_EMAIL_* on the auth service (see The Send Email
hook).
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. Your deployed backend has its own limits, and they start lower: a
new Supabase Cloud project sends 2 emails per hour, failed sends included,
until you raise it once the Send Email hook is on (see The Send Email
hook). Its per-address gap
starts at 60 seconds, and the kit sets 45 (see Password and code
rules). Self-hosted,
both are environment on auth (see Auth defaults that
differ).
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 same setting on your deployed
backend: the matching Auth setting in the Supabase Cloud dashboard, or
GOTRUE_SECURITY_MANUAL_LINKING_ENABLED: "true" on a self-hosted auth
service) 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.