🚀 Join the waitlist now! waitlist.floot.dev
LogoFlootdocs
Auth

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.

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.

EmailTemplate
Sign-up confirmationsupabase/functions/send-email/_templates/sign-up/confirm-email.tsx
Magic linksupabase/functions/send-email/_templates/magic-link/magic-link.tsx
Password resetsupabase/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.

supabase/config.toml
[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.

supabase/functions/_shared/env.ts
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:

supabase/.env.local
SMTP_FROM="My App <noreply@acme.com>"
SMTP_HOST=host.docker.internal
SMTP_PASSWORD=mailpit
SMTP_PORT=54325
SMTP_USER=mailpit

host.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:

supabase/config.toml
[local_smtp]
enabled = true
port = 54324
smtp_port = 54325

Moving 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.

supabase/.env.local
SMTP_FROM="My App <noreply@myapp.com>"
SMTP_HOST=smtp.resend.com
SMTP_PASSWORD="<your api key>"
SMTP_PORT=587
SMTP_USER=resend

Mind the port. supabase/functions/_shared/clients.ts decides TLS purely from the number:

supabase/functions/_shared/clients.ts
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:

KeyValueWhy 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_expiry3600Access tokens live an hour; refresh-token rotation is on, with a 10-second reuse window.
enable_signuptrueTurn this off to freeze the user base without touching the app.
enable_anonymous_sign_insfalseNo guest sessions. Nothing in the app asks for one.
enable_manual_linkingfalseIdentities are read-only; see below.
minimum_password_length8Server 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:

KeyValueWhy it matters
enable_confirmationstrueThe address must be confirmed before sign-in works. This is what routes a new user to /email-verification.
otp_length6Matches the six boxes in AuthOtpVerifier. Change one and you must change the other.
otp_expiry3600A 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_changestrueAn email change has to be confirmed from both the old and the new address.
secure_password_changefalseupdatePassword 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.

supabase/config.toml
[auth.rate_limit]
email_sent = 60

[auth.email]
max_frequency = "45s"
KeyValueWhat it means
max_frequency45sMinimum gap between two emails to the same address. This is the one you hit during development.
email_sent60Ceiling 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.

supabase/config.toml
enable_manual_linking = false

With 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.

What's next

On this page