A Lovable password reset link that opens localhost:3000means the Supabase Site URL was never changed from its default. A link that says it's invalid or expired usually means an email scanner opened it first. No email at all usually means the built-in mailer, which sends two emails an hour and only to your own team. Each has a five-minute fix.
Password reset is easy to skip when testing before launch, because it works in the Lovable preview and nobody forgets a password in week one. Then a real customer does, the email opens a page that doesn't exist, and they're locked out of something they paid for. This guide goes from what the customer saw to the setting that caused it. It's written for the founder, with enough detail for whoever helps them.
Which password reset problem do you have?
Ask the customer, or try it yourself on the live app with a real email address. What the link does when it's clicked tells you which cause to read.
| What happens | Usual cause | Fix |
|---|---|---|
Link opens http://localhost:3000 | Site URL never changed from the default | Set the Site URL to the live domain (cause 1) |
| Link opens the home page, not the reset page | Reset page not on the redirect URLs list | Add the exact page to the list (cause 2) |
| "Email link is invalid or has expired" on first click | A mail scanner opened the link first and used the token | Point the email at your own page with a token hash (cause 3) |
| Works for you, fails for customers | Built-in mailer sends only to the project's team | Set up custom SMTP (cause 4) |
| No email, or only the first one or two arrive | Two emails an hour on the built-in mailer | Custom SMTP, then raise the limit (cause 4) |
| Reset page loads, then bounces to login | The app treats the recovery session like a normal login | Handle the recovery event on the reset page (cause 5) |
| Fixed yesterday, still broken today | Testing with an email sent before the fix | Request a fresh email after every change |
Cause 1: the Site URL is still localhost:3000
Supabase builds every auth email around one setting, the Site URL. Its docs describe it as the default address users are sent to when the code doesn't name one, and say it's critical for email confirmations and password resets. The docs also tell you to change it from http://localhost:3000to your production address, because that's what a new project starts with. If nobody did, every reset email points at a developer's laptop.
This is common enough to have a GitHub issue. In October 2025 a user reported password reset and invite emails from a hosted Supabase project carrying redirect_to=http://localhost:3000, so nobody outside the office could finish a reset. The thread closed after they were pointed at the redirect URL guide, which is the same fix as here.
In your Supabase project, open Authentication, then URL Configuration, and set the Site URL to the address customers use, with the scheme and no trailing slash: https://app.example.com. Save it. Then request a new reset email, because emails sent before the change keep the old address inside the link. If your app runs on Lovable's built-in backend rather than your own Supabase project, Lovable sets this for you, as the section on the built-in backend explains. That's also the first setting to recheck after moving a Lovable app to your own hosting, because the domain changes and the setting doesn't.
Cause 2: the reset page isn't in the redirect URLs
Fixing the Site URL sends customers to your domain. It doesn't send them to the reset page. For that, the code passes a redirectToaddress when it requests the email, and Supabase's docs say that address should point at your change password page and should match the Redirect URLs list. If it isn't on the list, the customer lands on the Site URL instead, which is usually the home page, logged in with a session they can't use.
AI-generated code usually builds the address from the page it's running on, which is the right idea:
const { error } = await supabase.auth.resetPasswordForEmail(email, {
redirectTo: `${window.location.origin}/update-password`,
});That works in the preview because the preview address is on the list. On your custom domain it fails until the domain is on the list too. Add the exact page, https://app.example.com/update-password, under Authentication, then URL Configuration, then Redirect URLs. Supabase supports wildcards: * matches one path segment, so https://app.example.com/* allows /update-password but not /auth/update, and ** matches everything after it. Its docs recommend the exact path in production and the wildcards for preview deployments, for example https://*-yourteam.vercel.app/**. If you use Lovable's own preview URL and a custom domain, both need an entry. If it isn't just the reset email but every sign-in that breaks on the custom domain, including Google, start with Lovable login not working on a custom domain, which covers the www and Google Cloud entries too.
Cause 3: email link is invalid or has expired
The full message is usually Email link is invalid or has expired, sometimes with error_code=otp_expiredin the address bar, and the customer swears they clicked within a minute. Supabase's troubleshooting page names the most common reason: email prefetching. Security tools at the customer's email provider open links in incoming mail to check them for malware. A password reset link is single-use, so the scanner uses it, and the real click arrives second. Business inboxes with link scanning switched on do this, which is why it hits business customers and not your Gmail test.
The default reset email uses {{ .ConfirmationURL }}, which verifies the token on the same request that opens it. Supabase's email template docs warn that this link gets consumed instantly by a prefetching provider and produces the expired error. Two fixes, and the docs describe both.
Point the email at your own page
Change the reset template so the link carries the token hash instead of confirming on open. In Authentication, then Email Templates, edit Reset Password:
<a href="{{ .SiteURL }}/update-password?token_hash={{ .TokenHash }}&type=recovery">
Reset your password
</a>Nothing happens when a scanner opens that page. The token is used only when the customer submits a new password, and the page does the verification itself:
const params = new URLSearchParams(window.location.search);
const { error } = await supabase.auth.verifyOtp({
token_hash: params.get("token_hash")!,
type: "recovery",
});
if (!error) {
await supabase.auth.updateUser({ password: newPassword });
}Or send a code instead of a link
The template docs also offer {{ .Token }}, a short code the customer types into your app. Nothing in the email is clickable, so nothing can be prefetched. It's one more step for the customer, and it's the option the troubleshooting page suggests when prefetching keeps happening.
Supabase also recommends turning off any email tracking at your mail provider, because tracking rewrites every link in the email and the rewritten reset link doesn't work.
Cause 4: no password reset email arrives
A new Supabase project sends auth emails through Supabase's own mail service, and its docs are blunt about what that service is for: testing and non-critical projects, with no delivery guarantee. Two limits catch Lovable apps after launch.
- It only delivers to your team.The custom SMTP guide says the default service refuses addresses that aren't members of the project's team, with an "Email address not authorized" error. That's why resets work for you and fail for every customer.
- Two emails an hour.The rate limits page gives the built-in provider's limit as 2 emails per hour for the whole project. Sign-up confirmations count toward it. On launch day, the third person who signs up gets nothing, and the sign-in form starts answering with Email not confirmed or Email rate limit exceeded.
- One request a minute per user.The same page says a password reset request for the same user defaults to a 60-second window. A customer who clicks "send again" straight away gets no second email, and your app should say so instead of failing quietly.
The fix is custom SMTP. Supabase's guide walks through it: create an account with a sending service (it names Resend, Postmark, AWS SES, SendGrid, Brevo, and ZeptoMail), take the host, port, username, and password, and enter them under Authentication, then SMTP settings. Once saved, the project sends to every address, and the limit starts at 30 messages an hour, which you raise on the Rate Limits page. Set up the sending domain's SPF, DKIM, and DMARC records with the provider, or the reset emails land in spam, which looks exactly like "no email" to the customer. This is one of the checks on the free vibe-coded app launch checklist.
Cause 5: the reset page doesn't finish the job
Sometimes the email is right, the link is right, and the customer still can't set a password. When they click, Supabase gives the browser a temporary session and sends it to your reset page. If the app isn't expecting that, it does what it does for any session: redirects to the dashboard, or to login, and the chance to change the password is gone. On the second click the link is used up.
Supabase's reference example handles this with the auth listener. When the event is PASSWORD_RECOVERY, the page should show the new password form and nothing else, then call updateUser:
supabase.auth.onAuthStateChange(async (event, session) => {
if (event === "PASSWORD_RECOVERY") {
setShowForm(true); // don't redirect, don't treat it as a login
}
});
async function submit(newPassword: string) {
const { error } = await supabase.auth.updateUser({ password: newPassword });
if (!error) router.replace("/login");
}Two things break this in generated code. A route guard that sends any signed-in user away from public pages fires before the form renders. And a redirect inside the same auth listener runs on the recovery event too. Put the reset page outside the guard, and make the listener do nothing on PASSWORD_RECOVERY except show the form. If you switched the template to a token hash in cause 3, the page verifies the token itself and this event never fires, which is simpler. The same listener is where the login hang in a Lovable app that hangs after login comes from, so fix both at once.
Lovable's built-in backend vs your own Supabase
Which fixes apply depends on where your app's backend lives. Lovable's docs say auth settings are configured through Lovable on the built-in backend, and in the Supabase dashboard when the app uses your own Supabase project, and that switching between the two isn't automatic.
On the built-in backend
Lovable's authentication docs say it sets the Site URL to your published lovable.appaddress when you publish, and adds your custom domain to the redirect list automatically. A Site URL you set yourself stays as you set it. So causes 1 and 2 are mostly handled for you, unless you overrode them. The reset email itself is customised under More, then Emails, and Lovable's email auth page says a per-hour cap on auth emails sits under Advanced in More, then Users, then Settings, then Email, and can be raised once email sending is set up for the project. Lovable's docs don't say whether the built-in backend exposes Supabase's SMTP or email template settings directly, so if you need the token hash change from cause 3, check the Emails view first, and ask Lovable support if it isn't there.
On your own Supabase project
Everything in this guide applies, nothing is automatic, and all of it lives in your Supabase dashboard under Authentication. Lovable only generates the code that calls it. That's the setup most apps with real customers end up on, and it's the one where the two-emails-an-hour default bites hardest, because nobody told you it was there.
Test the password reset flow before launch
Login problems were one of the things wrong with the social challenge appwhen I took it over from an agency, alongside payments and security, on a product that moves real money. The fix for a reset flow is small. Finding out from a locked-out customer is not. Run this on the live domain, not the preview, with an inbox that isn't on the Supabase team:
- Request a reset for a Gmail address and for a business address with link scanning on, such as a company Microsoft 365 inbox. The second one catches prefetching.
- Open the email on a phone, in the mail app's built-in browser. Confirm it lands on the reset page, not the home page or login.
- Set a new password, then log in with it on a different device.
- Click the same link again. It should say it's expired, with a clear way to request a new one.
- Request three resets inside an hour from three addresses. If the third never arrives, you're still on the built-in mailer.
- Check the spam folder for every email above. If any landed there, fix SPF, DKIM, and DMARC before launch.
When to get help with Lovable password reset
If the symptom table pointed at one cause and the fix took ten minutes, you're done. Get help when the reset is one of several auth problems, when customers report being able to see things they shouldn't after a reset, or when the app is about to take payments and you haven't tested any of the login flows end to end. A broken reset and a leaky database often arrive together, because both come from settings nobody opened. The Supabase RLS checklist covers the second one.
Logins and auth are one of the six areas in my Launch-Ready Review, a fixed-price check of a vibe-coded app for $900–1,500 in three to five days, with a written report and a priced fix list. If the list is long, fixing it is a Production Sprint for $3,000–7,500. What a one-off fix costs instead is in what it costs to fix a vibe-coded app. Either way, send me the app's linkand a screenshot of what the reset email opens, and I'll tell you which cause it is.
Sources
- Redirect URLs: Site URL default, wildcards, and Netlify and Vercel previews, Supabase Docs
- Password-based auth: resetting a password with resetPasswordForEmail, Supabase Docs
- resetPasswordForEmail reference, with the PASSWORD_RECOVERY example, Supabase Docs
- OTP verification failures: email prefetching consumes the token, Supabase Docs
- Email templates: link prefetching, the Token variable, and the TokenHash variable, Supabase Docs
- Auth rate limits: 2 emails an hour on the built-in provider, 60 seconds per user, Supabase Docs
- Custom SMTP: the default service sends only to team members, 2 messages an hour, Supabase Docs
- Password reset and invite links use localhost:3000 in production (issue #39718), GitHub, supabase/supabase
- Authentication: Site URL and redirect URLs on the built-in backend, Lovable Docs
- Email auth: reset emails, the per-hour cap, and where the settings live, Lovable Docs
- Supabase integration: auth settings live in your Supabase project, Lovable Docs



