Example finding How it works Coverage PENTEST METHODOLOGY DOCS PRICING FAQ MCP CONTACT LOG IN SIGN UP →

Password reset link never expires: how to fix it

FIX / RESET LINK EXPIRY / 9 MIN READ

A password reset link must expire in a short time and must work only once, so set an expiry of 15 to 60 minutes, mark the token as used after the first success, and delete all older tokens when a new one is issued. A reset link that never expires is a permanent key to the account. Anyone who finds the old email can take over the account.

This guide shows how to test your reset flow, how to fix the three common faults, and how to check if someone already used an old link.

It means that the link in the reset email still works after a long time, after the user already used it, or after the user asked for a new link. Each case is a separate fault. An app can have one, two, or all three.

A reset link holds a token. The token proves that the person who holds the link controls the email inbox. The server must treat the token as a password for a short time. When the token has no limits, it becomes a password that never changes.

The risk is real for these reasons:

  • Email stays in many places. Old emails remain in inboxes, in backups, on shared computers, and in forwarded messages.
  • Another person can read the inbox. This includes a coworker, a family member, or an attacker who has access to a mailbox.
  • The link can leak by other ways. A browser history, a proxy log, or a Referer header can copy the full URL.

The OWASP Forgot Password Cheat Sheet lists the rules. A reset token must be random, long, stored securely, and single use. It must also expire after an appropriate period. OWASP does not give one fixed number of minutes. Pick a short time that fits your users.

AI coding tools often create this fault in custom reset flows. You ask for a “forgot password” feature. The tool writes a token, stores it, and sends the email. It often forgets the expiry check and the “used” flag, because the feature works without them.

Run these four tests on your live app. Use a test account that you own, with a test email address.

  1. Test reuse. Request a reset email. Open the link and set a new password. Open the same link again. If the app lets you set a password a second time, the link is reusable.
  2. Test replacement. Request a reset email and keep it. Request a second reset email. Open the first link. If the first link still works, the app does not delete old tokens.
  3. Test age. Request a reset email. Wait longer than your intended expiry. Open the link. If it still works after a day, the link has no useful expiry.
  4. Test the change of password. Request a reset email. Do not use it. Change the password from the account settings. Then open the reset link. If it still works, a password change does not cancel old tokens.

For the age test, you do not need to wait. If you use your own database, change the creation time of the stored token to an old date and open the link. If you use a service, read the expiry in its settings, and then test the real time once.

Look at the token itself. Open the link from the email and read the URL:

  • A short or numeric token, such as 6 digits, is easy to guess if the app has no rate limit. See the brute-force warning in the OWASP guide.
  • A token that holds the user id, the email, or the time in plain text is not random. Someone can build a valid token for another user.
  • A token that is the same for the same user each time is not random either. Request two reset emails and compare them.

Also check the page that opens from the link. If the page loads third-party scripts or images, the browser can send the full URL, with the token, in the Referer header. OWASP says that the reset page must use a Referrer-Policy of no-referrer.

Warning: Run these tests only against your own app and your own test accounts.

If any test shows a fault, go to the next section.

What do I do first? Fix it now, in order of urgency

Do the steps in this order. The first step closes all open old links at once.

Caution: Step 1 stops all pending reset flows. Users who wait for a reset email must ask for a new one. This is the correct result.

  1. Delete all existing reset tokens. Run a delete on your token table. Old links stop working at once. If you use a service, check how to revoke pending links.
  2. Set an expiry on new tokens. Store the creation time or expiry time with each token. Reject a token that is older than your limit.
  3. Make each token single use. Delete the token, or set used_at, in the same database transaction that changes the password.
  4. Delete older tokens when a new one is issued. A user can have only one valid token at a time.
  5. End all sessions after a reset. Remove the login sessions and refresh tokens for the user.
  6. Store only a hash of the token. A leak of the database then does not give working links.
  7. Add rate limits and a neutral response. Limit the requests for each email and each IP address. Return the same message for known and unknown emails.
  8. Run the four tests again.

Fix a custom reset flow

Use a table with a hash, an expiry, and a used flag. This example uses PostgreSQL:

create table password_reset_tokens (
  id bigint generated always as identity primary key,
  user_id uuid not null references users (id) on delete cascade,
  token_hash text not null unique,
  expires_at timestamptz not null,
  used_at timestamptz
);

delete from password_reset_tokens;

The last line completes step 1 and deletes all open tokens.

Create the token with a secure random generator. Send the plain token in the email, and store only the hash. This example uses Node.js:

import { randomBytes, createHash } from "node:crypto";

const sha256 = (s: string) => createHash("sha256").update(s).digest("hex");

export async function createResetToken(userId: string) {
  await db.query("delete from password_reset_tokens where user_id = $1", [userId]);

  const token = randomBytes(32).toString("base64url");
  await db.query(
    `insert into password_reset_tokens (user_id, token_hash, expires_at)
     values ($1, $2, now() + interval '30 minutes')`,
    [userId, sha256(token)],
  );
  return token;
}

The function deletes old tokens first. Then it creates a token with 32 random bytes and an expiry of 30 minutes. You can use another time. A token from a secure random generator with 32 bytes is long enough that nobody can guess it.

Use the token in one atomic step. The update below marks the token as used and checks the expiry in the same statement. Two requests with the same token cannot both succeed:

export async function resetPassword(token: string, newPassword: string) {
  const client = await db.connect();
  try {
    await client.query("begin");
    const { rows } = await client.query(
      `update password_reset_tokens
         set used_at = now()
       where token_hash = $1
         and used_at is null
         and expires_at > now()
       returning user_id`,
      [sha256(token)],
    );
    if (rows.length === 0) {
      await client.query("rollback");
      return { ok: false };
    }
    const userId = rows[0].user_id;
    await client.query("update users set password_hash = $1 where id = $2", [
      await hashPassword(newPassword),
      userId,
    ]);
    await client.query("delete from sessions where user_id = $1", [userId]);
    await client.query("delete from password_reset_tokens where user_id = $1", [userId]);
    await client.query("commit");
    return { ok: true };
  } catch (e) {
    await client.query("rollback");
    throw e;
  }
}

The functions hashPassword, db, and the sessions table are placeholders. Use the names from your own project. The function does five things in one transaction. It checks the token, marks it used, saves the new password, removes the sessions, and removes the remaining tokens.

Return an error with the same text for an unknown token, an expired token, and a used token. A different message tells an attacker which tokens existed.

Fix the reset page

Add these protections to the page that the link opens:

  1. Serve the page only over HTTPS.
  2. Send the header Referrer-Policy: no-referrer, as OWASP recommends.
  3. Do not load third-party scripts, analytics, or images on this page.
  4. Build the link from a fixed domain in your configuration. Do not use the Host header of the request. OWASP warns that a link built from the Host header can point to the attacker’s domain.

Fix the flow in Supabase Auth

Supabase Auth issues and checks the token for you, so you do not write the token code. Check the settings and your own page instead.

According to the Supabase passwordless documentation, email links are one-time use and expire after 1 hour. The Email OTP expiration setting also controls the validity of email links. The same page says that an expiry of more than 86,400 seconds (one day) is strongly discouraged. The Supabase production checklist recommends 3600 seconds or lower.

  1. Open the Supabase dashboard and go to the Auth settings for email.
  2. Find the Email OTP Expiration setting.
  3. Set a value of 3600 seconds or lower. Use a lower value if your users can open the email fast.
  4. Test the flow with the four tests in the confirm section.

The Supabase password guide says that the change-password page must be reachable only by authenticated users. After the link is verified, the user has a session. The page then calls updateUser({ password }). Make sure that your page does not show the form to a visitor without a session.

If your app uses its own reset page on top of Supabase, check that it does not store the token in a place that other code can read, such as a log or an analytics event.

For the Lovable-specific flow, read Lovable forgot password.

Look for password changes and logins that the owner did not make. Use the data that you have.

  1. Check the token table. Find tokens with used_at set at a time that the user does not know. Compare the time with the user’s report.
  2. Check the password change time. If you store a password_changed_at field, find accounts that changed without a support request.
  3. Check the logins after a reset. Look for a login from a new country or a new device soon after a reset.
  4. Check the email logs. Your email service shows which reset emails were sent. Look for many emails to the same address.
  5. Check the web logs. Look for repeated requests to the reset URL with different tokens. This pattern shows a guessing attempt.
  6. Check the Supabase Auth logs. In the dashboard, open the logs and look for recovery events. Compare them with the user’s actions.
  7. Contact the affected users. Ask them if they made the reset. Tell them to check their own account for changes.

If you cannot prove that an old link was never used, force a password reset for the accounts that had an open token. Then end all of their sessions.

Note: The logs of a reset link do not show who held the link. They show only the requests. An attacker who used a valid link looks the same as the owner.

How do I stop this from happening again?

Use these controls for every auth flow that sends a link or a code.

  1. Use a short expiry. Set the time in one place in the configuration. Review it when the app changes.
  2. Make every token single use. Do this for reset links, magic links, invite links, and email verification links.
  3. Issue one valid token for each user. A new token cancels the old one.
  4. Hash tokens in storage. Treat a reset token like a password.
  5. End sessions after a reset. Do the same after a password change in the settings page.
  6. Use a trusted auth service when you can. A tested service handles the token rules for you. You still need to test the flow.
  7. Rate limit the reset endpoints. Limit the number of reset emails for each address, and the number of token checks for each IP address.
  8. Add the four tests to the release checklist. Run them after each change to the auth code.

For more flaws in sign-in flows that use links and codes, read auth flows: magic links, OTP, and reset. For the full list of auth controls, read authentication implementation.

When should I get help?

Get help in these cases:

  • You find evidence that someone used an old link to take over an account.
  • Many accounts changed their passwords at times that the owners do not know.
  • Your app is a custom auth system, and you do not know all the places that create or check tokens.
  • Your app holds payment data, health data, or business data, and you cannot show that the accounts are safe.
  • The reset email also gives access in other ways, for example a link that signs the user in without a password.

After the fix, run the four tests again. A correct flow rejects the second use of a link, rejects an old link after a new request, rejects an expired link, and ends the old sessions.

Common questions

How long must a password reset link stay valid?
Use a short time, for example 15 to 60 minutes. OWASP gives no fixed number and says the token must expire after an appropriate period. A shorter time gives an attacker less time.
Must a reset link work only one time?
Yes. The server must mark the token as used when the password changes. OWASP says that tokens must be invalidated after use.
Is a JWT a good reset token?
A JWT can work, but a random token that you store as a hash is simpler. You can delete a stored token at once. A JWT stays valid until its expiry unless you add a block list.
Do I need to sign out other sessions after a reset?
Yes. A person who reset a password often does so because of a suspected takeover. End all other sessions so that a stolen session stops working.
Does Supabase Auth have this problem by default?
The Supabase documentation says that links are single use and expire after the OTP expiry time. The default is 1 hour. Check your own setting, and test the flow.

Test your reset flow

A VibeEval scan tests the reset flow of your deployed app and reports reset links that work again after use or that stay valid after a long time.

14-day free trial · No credit card · Cancel anytime

SCAN YOUR APP →