securitynextjspostgresauthenticationnodejs

I opened 4 issues against my own password reset flow. Here’s what I found.

Password reset looks like a 20-line feature, until it has an email leak, a half-finished transaction, and a pile of live tokens. A build-along: schema, tokens, atomic reset, a race test, and the page that’s still missing.

Four Bugs in My Password Reset Flow (and How I Fixed Them)

Auth was supposed to be the boring part.

I was building a task manager API for a skill assessment. Then I started reviewing my own password reset like someone trying to break it, and found four bugs. Not one of them was a typo.

This post is both things at once: the story of those four bugs, and a build-along. By the end you'll have a complete, defensible password reset flow in Next.js and Postgres: the schema, the token handling, the endpoints, a race-condition test, and the page that finishes the job.

ℹ️

How to read this. Each step is a piece of the flow you'd build anyway. Where I got it wrong the first time, I show the broken version, explain why it breaks, then show the fix. The snippets are simplified to teach the pattern. The real implementation lives in the repo, and where they differ, trust the repo.

In this post

  1. The four bugs at a glance
  2. Stack and prerequisites
  3. Step 1: The schema
  4. Step 2: Tokens you can't guess and can't leak
  5. Step 3: forgot-password that never tells (Bug 1)
  6. Step 4: Login that takes the same time (Bug 2)
  7. Step 5: The reset endpoint, atomically (Bug 3)
  8. Step 6: Killing the other links (Bug 4)
  9. Step 7: Prove it with a race test
  10. Step 8: The page that doesn't exist yet
  11. Step 9: Rate-limit the endpoints
  12. What I haven't fixed
  13. The checklist
  14. What I'm asking you

The four bugs at a glance

Three of them are tracked on GitHub: issue #12, issue #14, and PR #11. Here's the short version before we build anything.

| # | What was wrong | Why it mattered | The fix | | --- | --- | --- | --- | | 1 | forgot-password answered differently for known and unknown emails (issue #12) | Anyone with a list of emails could check who has an account | Always return the same 200, even when the email send fails | | 2 | Login was faster for unknown emails | The response time leaked the same fact through a stopwatch | Compare against a dummy hash when the user doesn't exist | | 3 | Resetting took three separate database steps (issue #14) | A failure halfway through, or two simultaneous requests, could leave the token usable | One atomic claim, inside one transaction | | 4 | Every reset request left the old links alive (PR #11) | Each email in the inbox was a live key | Using any token closes all the others, in the same transaction |

Stack and prerequisites

Nothing exotic here. If you've built a CRUD API before, you can follow along.

  • Next.js route handlers (App Router)
  • Postgres through the pg driver. I used Supabase, but any Postgres works.
  • Zod for request validation
  • bcryptjs for password hashing
  • An email provider you can call from a function. I'll use a placeholder sendResetEmail(), so plug in Resend, Postmark, SES, whatever you have.

You should be comfortable with async/await, basic SQL, and the idea of a database transaction. You do not need to know anything about isolation levels. I'll explain the one thing that matters.

terminal
npm i pg zod bcryptjs
npm i -D vitest @types/pg @types/bcryptjs
lib/db.ts
// lib/db.ts
import { Pool } from "pg";

export const pool = new Pool({
  connectionString: process.env.DATABASE_URL,
  max: 5,
});
ℹ️

Serverless note. If you deploy on Vercel with Supabase, use the pooled connection string (the pooler, not the direct one) and keep max small. Every serverless instance opens its own pool, and Postgres has a hard connection limit.

Step 1: The schema

Two tables matter: users, and reset tokens. The design decisions are all in the tokens table.

schema.sql
create table users (
  id            uuid primary key default gen_random_uuid(),
  email         text not null unique,
  password_hash text not null,
  created_at    timestamptz not null default now()
);

create table reset_tokens (
  id         uuid primary key default gen_random_uuid(),
  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,
  created_at timestamptz not null default now()
);

-- lets us find a user's still-open tokens quickly
create index reset_tokens_open_idx
  on reset_tokens (user_id)
  where used_at is null;

Four things to notice:

  1. We store token_hash, never the token. The raw token only exists in the email. If your database leaks, the attacker gets hashes they can't turn back into working links.
  2. token_hash is unique. That gives us a fast indexed lookup, and it means the database itself refuses duplicates.
  3. used_at instead of deleting the row. A used token stays as a record. A null means still open, which the conditional index uses.
  4. expires_at is stored, not computed. Changing the lifetime later doesn't touch old rows.

Step 2: Tokens you can't guess and can't leak

A reset token is a password that works once and is delivered over email. Treat it like one.

lib/tokens.ts
// lib/tokens.ts
import { randomBytes, createHash } from "node:crypto";

export const RESET_TTL_MINUTES = 60;

export function newResetToken() {
  const token = randomBytes(32).toString("base64url"); // 256 bits of randomness
  return { token, tokenHash: hashToken(token) };
}

export function hashToken(token: string) {
  return createHash("sha256").update(token).digest("hex");
}

Why SHA-256 and not bcrypt?

Passwords are low-entropy, so you hash them slowly on purpose to make guessing expensive. A reset token is 256 random bits. Nobody is guessing that, so a fast hash is fine, and you need it to be deterministic so you can look the token up by its hash. Bcrypt generates a new salt every time, so you couldn't do where token_hash = $1.

Why randomBytes and not Math.random() or a UUID?

Math.random() isn't cryptographically secure. A v4 UUID is random, but it only has 122 bits of it and the format is more predictable than you'd like for a credential. crypto.randomBytes is the right tool, and base64url makes the result safe to put in a URL.

❌

Bug 1 / Issue #12

Step 3: forgot-password that never tells

The usual version of this endpoint says "no account found" for an unknown email and "email sent" for a known one. That's friendly, and it's also a free lookup tool for anyone with a list of emails.

The broken version

the leaky version
// DON'T
const user = await findUserByEmail(email);
if (!user) return Response.json({ error: "No account with that email" }, { status: 404 });

await sendResetEmail(user.email, token);
return Response.json({ message: "Email sent" });

The fixed version

app/api/auth/forgot-password/route.ts
// app/api/auth/forgot-password/route.ts
import { z } from "zod";
import { pool } from "@/lib/db";
import { newResetToken, RESET_TTL_MINUTES } from "@/lib/tokens";
import { sendResetEmail } from "@/lib/email";

const Body = z.object({ email: z.string().email().max(254) });

const SAME_ANSWER = {
  message: "If that email is registered, a reset link is on its way.",
};

export async function POST(req: Request) {
  const parsed = Body.safeParse(await req.json().catch(() => null));
  if (!parsed.success) return Response.json({ error: "Invalid request" }, { status: 400 });

  const email = parsed.data.email.toLowerCase();
  const { rows } = await pool.query("select id, email from users where email = $1", [email]);
  const user = rows[0];

  if (user) {
    const { token, tokenHash } = newResetToken();
    await pool.query(
      `insert into reset_tokens (user_id, token_hash, expires_at)
       values ($1, $2, now() + make_interval(mins => $3))`,
      [user.id, tokenHash, RESET_TTL_MINUTES]
    );

    try {
      await sendResetEmail(user.email, token);
    } catch (err) {
      // swallow it: log for yourself, but never change the response
      console.error("reset email failed", { userId: user.id, err });
    }
  }

  return Response.json(SAME_ANSWER); // identical for known and unknown emails
}

Two rules to take from this:

  • The response is a constant. Same status, same body, every time.
  • A failed email send is not allowed to change the response. If the provider is down and you return a 500 only for real accounts, you've rebuilt the leak. Log the failure and move on.
⚠️

A limitation you should know about. The response is identical, but the time isn't. The known-email path does an insert and an email call, so it's slower. A patient attacker with a stopwatch can still tell the difference. The standard fix is to push the work off the request: queue the email, or run it after the response is sent. Check how your own implementation handles this before you assume it's closed.

❌

Bug 2

Step 4: Login that takes the same time

Same idea, different channel. A wrong password and an unknown email both return the same 401, but that's not enough. If the unknown-email path skips the password hash, it answers faster, and the difference shows up in a stopwatch.

So for an unknown email, compare against a dummy hash anyway. Same work, same response, same time. It feels silly to hash a password for an account that doesn't exist, but that's the point.

app/api/auth/login/route.ts
// app/api/auth/login/route.ts (the relevant part)
import bcrypt from "bcryptjs";

const COST = 12;

// computed once, when the module loads, with the SAME cost as real hashes
const DUMMY_HASH = bcrypt.hashSync("this-account-does-not-exist", COST);

export async function POST(req: Request) {
  const { email, password } = await readAndValidate(req);

  const user = await findUserByEmail(email);

  // always run a compare, against the real hash or the dummy
  const hash = user?.password_hash ?? DUMMY_HASH;
  const ok = await bcrypt.compare(password, hash);

  if (!user || !ok) {
    return Response.json({ error: "Invalid email or password" }, { status: 401 });
  }

  return issueJwtFor(user);
}
ℹ️

Detail that matters. The dummy hash must use the same cost factor as real ones. If real hashes use 12 and the dummy uses 4, you've swapped one timing difference for another.

❌

Bug 3 / Issue #14

Step 5: The reset endpoint, atomically

This is the one that taught me the most. Here's how I first wrote it, and I suspect it's how most of us first write it.

The broken version

the three-step version
// DON'T: three steps, no transaction
const found = await pool.query(
  "select user_id from reset_tokens where token_hash = $1 and used_at is null and expires_at > now()",
  [tokenHash]
);
if (found.rowCount === 0) return invalid();

await pool.query("update users set password_hash = $1 where id = $2", [newHash, found.rows[0].user_id]);
await pool.query("update reset_tokens set used_at = now() where token_hash = $1", [tokenHash]);

It reads fine, and it has two separate bugs:

  • Failure in the middle. If step 2 succeeds and step 3 fails, the password changed but the token still works. Anyone holding that link can reset it again.
  • The race. Two requests with the same link, arriving together, both pass step 1 before either reaches step 3. Both win.

Both come from the same mistake: checking and claiming are separate operations, so something can happen between them.

The fix: claim in one statement

Don't check whether the token is usable and then mark it used. Mark it used and find out if you succeeded, in the same statement:

the claim
update reset_tokens
   set used_at = now()
 where token_hash = $1
   and used_at is null
   and expires_at > now()
returning user_id;

If it returns a row, you won. If it returns nothing, the token was wrong, expired, or already used, and you can't tell which from the outside (which is exactly what you want to tell the caller).

Why two requests can't both win

When two transactions run this update at the same moment, Postgres makes the second one wait for the first to finish. When the first commits, the second re-checks the where clause against the new row. used_at is no longer null, so it matches zero rows and returns nothing. The database picks the winner, not your code.

The whole endpoint

app/api/auth/reset-password/route.ts
// app/api/auth/reset-password/route.ts
import { z } from "zod";
import bcrypt from "bcryptjs";
import { pool } from "@/lib/db";
import { hashToken } from "@/lib/tokens";

const Body = z.object({
  token: z.string().min(20).max(200),
  password: z.string().min(8).max(128),
});

const INVALID = { error: "This reset link is invalid or has expired." };

export async function POST(req: Request) {
  const parsed = Body.safeParse(await req.json().catch(() => null));
  if (!parsed.success) return Response.json({ error: "Invalid request" }, { status: 400 });

  const { token, password } = parsed.data;

  // slow work FIRST, outside the transaction
  const newHash = await bcrypt.hash(password, 12);
  const tokenHash = hashToken(token);

  const client = await pool.connect();
  try {
    await client.query("BEGIN");

    // 1. claim the token atomically
    const claimed = await client.query(
      `update reset_tokens
          set used_at = now()
        where token_hash = $1
          and used_at is null
          and expires_at > now()
      returning user_id`,
      [tokenHash]
    );

    if (claimed.rowCount === 0) {
      await client.query("ROLLBACK");
      return Response.json(INVALID, { status: 400 });
    }

    const userId = claimed.rows[0].user_id;

    // 2. set the new password
    await client.query("update users set password_hash = $1 where id = $2", [newHash, userId]);

    // 3. kill every other open token for this user (Bug 4, next step)
    await client.query(
      "update reset_tokens set used_at = now() where user_id = $1 and used_at is null",
      [userId]
    );

    await client.query("COMMIT");
    return Response.json({ message: "Password updated." });
  } catch (err) {
    await client.query("ROLLBACK");
    throw err;
  } finally {
    client.release();
  }
}
ℹ️

Why hash before the transaction? Bcrypt takes a couple of hundred milliseconds on purpose. If you hash inside the transaction, you hold a connection and a row lock for that whole time. Do the slow work first, then keep the transaction short.

⚠️

The trade-off. Hashing first means a request with a garbage token still costs you a bcrypt. That's a cheap way for someone to burn your CPU, so rate-limit this endpoint (Step 9). The snippet makes that trade to keep the transaction short. You might make a different call.

❌

Bug 4 / PR #11

Step 6: Killing the other links

Look at what the forgot-password endpoint does: every click mints a new token and leaves the old ones alone. Click "forgot password" three times, and you have three emails in your inbox, three live keys. A reset from any one of them works.

That's a larger attack surface than it looks. An old email sitting in a shared inbox, a forwarded message, a stale copy in a backup: all of them are valid until they expire.

The fix is the last statement you already saw in the transaction:

inside the same transaction
update reset_tokens
   set used_at = now()
 where user_id = $1
   and used_at is null;

Using one token closes all the others for that user. Because it's in the same transaction as the password change, you can't end up with a changed password and open siblings, or the reverse.

An alternative worth knowing

You can also close old tokens when a new one is requested, so only the latest link ever works. Either approach is valid. I close them on use because it also covers the case where an attacker gets a link out of an old email after you've reset.

Housekeeping

Used and expired rows pile up. A scheduled job that deletes rows older than a few days is enough:

cleanup.sql
delete from reset_tokens
 where expires_at < now() - interval '7 days';

Step 7: Prove it with a race test

I don't trust a concurrency fix I haven't watched fail first. This test fires two resets with the same token at once and asserts that exactly one wins. Run it against your broken version first to see it fail, then against the fixed one.

tests/reset.race.test.ts
// tests/reset.race.test.ts
import { describe, it, expect } from "vitest";
import { POST as reset } from "@/app/api/auth/reset-password/route";
import { seedUserWithToken } from "./helpers";

function call(token: string, password: string) {
  return reset(
    new Request("http://test/api/auth/reset-password", {
      method: "POST",
      headers: { "content-type": "application/json" },
      body: JSON.stringify({ token, password }),
    })
  );
}

describe("password reset", () => {
  it("lets only one of two simultaneous requests win", async () => {
    const { token } = await seedUserWithToken();

    const results = await Promise.all([
      call(token, "NewPassw0rd!A"),
      call(token, "NewPassw0rd!B"),
    ]);

    const statuses = results.map((r) => r.status).sort();
    expect(statuses).toEqual([200, 400]);
  });

  it("rejects the same token a second time", async () => {
    const { token } = await seedUserWithToken();
    expect((await call(token, "NewPassw0rd!A")).status).toBe(200);
    expect((await call(token, "NewPassw0rd!B")).status).toBe(400);
  });

  it("invalidates sibling tokens", async () => {
    const { token, siblingToken } = await seedUserWithToken({ siblings: 1 });
    expect((await call(token, "NewPassw0rd!A")).status).toBe(200);
    expect((await call(siblingToken, "NewPassw0rd!B")).status).toBe(400);
  });
});
⚠️

Honest caveat. Race tests can pass by luck. Running two requests with Promise.all makes a collision likely, not guaranteed. Run it a few times, or loop it 50 times in a single test, before you trust a green result. This is the same reason the idempotency gap further down is hard to notice.

tests/helpers.ts
// tests/helpers.ts: the shape of the seed helper
export async function seedUserWithToken(opts: { siblings?: number } = {}) {
  // 1. insert a user with a random email
  // 2. create a token with newResetToken(), insert its hash, expiring in 60 min
  // 3. optionally insert N more tokens for the same user
  // 4. return the raw tokens, because the hashes are useless to the caller
}

Step 8: The page that doesn't exist yet

The reset email links to a /reset-password page. In my repo, that page doesn't exist yet, so the link 404s. You can finish a reset from the playground, or with POST /api/auth/reset-password and { token, password }. It's the first thing on my list, so here's roughly what it looks like:

app/reset-password/page.tsx
// app/reset-password/page.tsx
"use client";

import { Suspense, useState } from "react";
import { useSearchParams } from "next/navigation";

function ResetForm() {
  const token = useSearchParams().get("token") ?? "";
  const [password, setPassword] = useState("");
  const [state, setState] = useState<"idle" | "busy" | "done" | "error">("idle");

  async function submit(e: React.FormEvent) {
    e.preventDefault();
    setState("busy");
    const res = await fetch("/api/auth/reset-password", {
      method: "POST",
      headers: { "content-type": "application/json" },
      body: JSON.stringify({ token, password }),
    });
    setState(res.ok ? "done" : "error");
  }

  if (!token) return <p>This link has no token. Get a fresh one.</p>;
  if (state === "done") return <p>Password updated. Sign-in works again.</p>;

  return (
    <form onSubmit={submit}>
      <label>
        New password
        <input
          type="password"
          minLength={8}
          autoComplete="new-password"
          value={password}
          onChange={(e) => setPassword(e.target.value)}
          required
        />
      </label>
      <button disabled={state === "busy"}>Set password</button>
      {state === "error" && <p>This link is invalid or expired. Get a fresh one.</p>}
    </form>
  );
}

export default function Page() {
  return (
    <Suspense>
      <ResetForm />
    </Suspense>
  );
}
  • Suspense is required around anything that calls useSearchParams in a client component, or the production build complains.
  • autoComplete="new-password" lets password managers offer to generate and save one.
  • Tokens in URLs leak. They end up in browser history and can be sent in the Referer header to any third-party script on the page. Send Referrer-Policy: no-referrer for this route, and keep analytics off it.
  • One generic error message. The page, like the API, never says why a token failed.

Step 9: Rate-limit the endpoints

Without limits, forgot-password is an email bomb: someone can flood a victim's inbox through your server. And as we saw, reset-password costs a bcrypt per request. Both need limits.

On serverless, an in-memory counter is useless because every instance has its own. I keep the counters in Postgres so all instances share them:

rate_limits.sql
create table rate_limits (
  key          text        not null,
  window_start timestamptz not null,
  hits         int         not null default 1,
  primary key (key, window_start)
);
lib/rate-limit.ts
// lib/rate-limit.ts
import { pool } from "@/lib/db";

export async function allow(key: string, limit: number, windowSeconds = 60) {
  const { rows } = await pool.query(
    `insert into rate_limits (key, window_start, hits)
     values ($1, to_timestamp(floor(extract(epoch from now()) / $2) * $2), 1)
     on conflict (key, window_start)
     do update set hits = rate_limits.hits + 1
     returning hits`,
    [key, windowSeconds]
  );
  return rows[0].hits <= limit;
}
applying it
// at the top of forgot-password
const ip = req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown";

if (!(await allow(`forgot:${ip}`, 5))) {
  // return the SAME answer: a different one would tell an attacker they were limited
  // on a real account but not a fake one
  return Response.json(SAME_ANSWER);
}
⚠️

Two choices to make on purpose. What you key on: limiting by IP alone lets a botnet through, and limiting by email alone lets an attacker lock a victim out of their own reset. Combining both keys is common. What you return when limited: on forgot-password, returning a different response when limited re-opens the enumeration hole from Step 3.

What I haven't fixed

I'd rather tell you than have you find it.

The idempotency race

My task-creation endpoint accepts an Idempotency-Key. Resend the same key and you get the original task back instead of a duplicate. It works, with one known gap: two simultaneous requests with the same brand-new key can both pass the lookup. The second one hits the primary key and returns a 500. Nothing gets duplicated, and a retry returns the stored task. But a 500 is still a 500.

It's the same class of bug as Bug 3: a check, then a write, with a gap between them. I know the shape of the fix. I'd like to see yours first.

The other loose ends

  • The /reset-password page doesn't exist in the repo yet (see Step 8).
  • The rate limiter is fixed-window. A client can burst roughly double the limit across a window boundary. At real scale this belongs in Redis.
  • The timing difference in forgot-password (Step 3) is worth auditing in any implementation, including mine.
  • The offline client isn't built. Only the server side of sync exists.

The checklist

If you only take one thing from this post, take this list and compare it to your own reset flow:

  • The token is random (crypto.randomBytes), long, and expires.
  • Only a hash of the token is stored.
  • The token is single-use, and the claim is one atomic statement.
  • The whole reset runs in one transaction.
  • Using a token closes every other open token for that user.
  • forgot-password returns the same response for known and unknown emails, including when the email fails.
  • Login does the same work whether or not the user exists.
  • Both endpoints are rate-limited, and limiting doesn't change the response.
  • The reset page sends Referrer-Policy: no-referrer.
  • There's a test that fires two requests at once.

What I'm asking you

  1. What did I miss in the reset flow? I'm sure there are more bugs.
  2. How would you close the idempotency race? I have a plan, but I want to see yours first.
  3. Want an easy first PR? The /reset-password page reads a token from the URL and POSTs it. That's it. The repo is acetennyson/global-tech-trial.

What's the worst thing you've ever found in your own auth code?

( Next post )I Almost Didn't Build Beta Gamer