sql typescript 97 lines · 3 tabs

Password reset tokens: hash + expiry

Shared by codesnips Jan 2026
3 tabs
CREATE TABLE password_reset_tokens (
  id          BIGSERIAL PRIMARY KEY,
  user_id     BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  token_hash  TEXT NOT NULL,
  expires_at  TIMESTAMPTZ NOT NULL,
  used_at     TIMESTAMPTZ,
  created_at  TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE UNIQUE INDEX idx_reset_tokens_hash ON password_reset_tokens (token_hash);
CREATE INDEX idx_reset_tokens_user ON password_reset_tokens (user_id);
3 files · sql, typescript Explain with highlit

Password reset flows are a classic place to leak security if raw tokens are stored in the database. This snippet demonstrates the safer pattern: a high-entropy random token is generated and emailed to the user, but only a SHA-256 hash of it is persisted. If the database is ever read by an attacker, the stored hashes are useless for redemption because the plaintext token never touches disk.

In passwordResetTokens migration, the password_reset_tokens table stores token_hash (unique) rather than the token itself, alongside an expires_at timestamp and a nullable used_at. The unique index on token_hash lets lookups be a single indexed equality check, and expires_at encodes the short-lived nature of the token directly in the row so expiry can be enforced in SQL.

In PasswordResetService, create uses crypto.randomBytes(32) for the plaintext — 256 bits of entropy makes brute-forcing infeasible — and derives the hash with hashToken, a thin wrapper over createHash('sha256'). A plain SHA-256 is appropriate here (unlike user passwords) because the input is already high-entropy and unguessable, so a slow adaptive hash like bcrypt would add cost without security benefit. Only the plaintext is returned to the caller for emailing; the row keeps the digest.

The verify method is where the trade-offs show. It hashes the incoming token, looks up the matching row, and rejects it if used_at is set or expires_at has passed. Marking the token consumed in consume enforces single use, closing the window where an intercepted link is replayed. Note that lookups are keyed by hash, so a timing side channel on the string compare is avoided — the database does the comparison on an indexed column.

In authRoutes, POST /password-reset always responds with the same generic message regardless of whether the email exists, preventing account enumeration. POST /password-reset/confirm calls verify then consume inside the same request, and only updates the password if verification succeeds. A pitfall worth noting: expired and used rows should be pruned by a periodic job, since they accumulate and the unique index only prevents hash collisions, not unbounded growth.


Related snips

Share this code

Here's the card — post it anywhere.

Password reset tokens: hash + expiry — share card
Link copied