Skip to content

Password Reset

@modularityjs/auth-local-password-reset issues hashed, single-use, expiring password-reset tokens for auth-local users. The package owns the token lifecycle and the password write; the app owns the mail and the throttling. @modularityjs/http-auth-local-password-reset adds the four HTTP routes on top.

It is the sibling of email verification — same token discipline, same store family, same enumeration-safety contract.

The service

typescript
abstract class PasswordResetTokenStore {
  abstract issue(
    tokenHash: string,
    record: PasswordResetRecord,
    expiresAt: Date,
  ): Promise<void>;
  abstract find(tokenHash: string): Promise<PasswordResetRecord | undefined>;
  abstract consume(tokenHash: string): Promise<PasswordResetRecord | undefined>;
  abstract deleteForUser(userId: string): Promise<number>;
  abstract purgeExpired(): Promise<number>;
}

PasswordResetService is the surface application code uses:

  • issue(username) — looks the user up via LocalUserStore, generates 32 bytes of CSPRNG entropy (base64url), stores only its SHA-256 hash with expiresAt = now + ttlMs, and returns { token, userId, attributes }. The plaintext token exists only in that return value — it is never persisted. An unknown username returns undefined rather than throwing.
  • findValid(token) — non-consuming check, for rendering the reset form before the POST that actually consumes the token.
  • consume(token, newPassword) — atomically deletes-and-returns the record (two concurrent consumes: exactly one winner), hashes newPassword through the wired PasswordHasher, writes it via LocalUserStore.updatePassword, then deletes every other outstanding token for that user. A second reset link captured earlier must not survive the password change it competed with.
typescript
import { PasswordResetService } from '@modularityjs/auth-local-password-reset';
import { Inject, Injectable } from '@modularityjs/di';
import { MailService } from '@modularityjs/mail';

@Injectable()
class AccountRecoveryService {
  constructor(
    @Inject(PasswordResetService)
    private readonly reset: PasswordResetService,
    @Inject(MailService) private readonly mail: MailService,
  ) {}

  async requestReset(username: string): Promise<void> {
    const issued = await this.reset.issue(username);
    if (!issued) return; // unknown user — respond exactly as if sent
    await this.mail.send({
      to: username,
      subject: 'Reset your password',
      text: `https://example.com/password/reset?token=${issued.token}`,
    });
  }
}

Configuration

typescript
AuthLocalPasswordResetModule.forRoot({ ttlMs: 30 * 60 * 1000 });
OptionDefaultDescription
ttlMs3_600_000 (1 h)Token lifetime; minimum 1000 ms, validated at boot

Reset tokens deliberately default to a much shorter life than verification tokens — a reset link is a live credential for the account.

Token stores

Pick exactly one store driver:

typescript
import { AuthLocalPasswordResetModule } from '@modularityjs/auth-local-password-reset';
import { AuthLocalPasswordResetMemoryModule } from '@modularityjs/auth-local-password-reset-memory';

const modules = [
  // ...AuthModule, AuthLocalModule + hasher + user store...
  AuthLocalPasswordResetModule,
  AuthLocalPasswordResetMemoryModule,
];
StoreNotes
@modularityjs/auth-local-password-reset-memoryBounded (maxEntries default 10_000, oldest-first eviction — the issuing route is unauthenticated); optional evictionIntervalMs sweep via expiry
@modularityjs/auth-local-password-reset-database-typeormpassword_reset_token table (entity ships with the package); consume races resolved by the delete's affected-row count
@modularityjs/auth-local-password-reset-database-prismaPrisma model (default delegate passwordResetToken; .forRoot({ model }) to rename); tokenHash PK, userId, attributes, indexed expiresAt

Stores never see plaintext. A leaked token table is unusable — reversing SHA-256 over 256 bits of entropy is infeasible.

HTTP routes

@modularityjs/http-auth-local-password-reset mounts the flow as four routes and leaves both the rendering and the delivery to the app:

RoutePurpose
GET /password/forgotRenders the "enter your email" form
POST /password/forgotIssues a token and hands it to PasswordResetDeliverer
GET /password/resetValidates the token (non-consuming) and renders the new-password form
POST /password/resetConsumes the token and sets the new password

Two seams keep the extension driver-agnostic:

  • PasswordResetDeliverer — the app implements how a link reaches the user (mail, SMS, log line in dev). The extension never assumes mail.
  • PasswordResetFormRenderer — the app implements the four views. Wire template-http if you want them rendered by the template engine.
typescript
HttpAuthLocalPasswordResetModule.forRoot({
  baseUrl: 'https://app.example.com',
  signInAfterReset: true,
});
OptionDefaultDescription
baseUrl— requiredAbsolute origin the reset link is built from; boot fails if unset
signInAfterResettrueEstablish a session immediately after a successful reset, via http-auth-session

The module depends on http-csrf — both POST routes are state-changing form submissions, and the reset POST carries a live credential.

Enumeration safety

issue returns undefined for an unknown username instead of throwing, so the route can respond identically in both branches ("if that account exists, we've sent a link"). A different status, body, or latency profile per branch lets an attacker enumerate which addresses have accounts. The bundled POST /password/forgot already does this; keep the same discipline if you write your own route.

Throttling stays at the route

The package does not rate-limit issue — abuse arrives at the HTTP edge, and that is an HTTP concern. Put http-rate-limit on POST /password/forgot, keyed by email and IP. Without it the endpoint is an open mail-sending relay pointed at your own users.

Expired-row cleanup

Expired records are never returned by find / consume, but the database stores do not delete them either — and since POST /password/forgot is unauthenticated, the table grows with every request until something prunes it.

PasswordResetTokenStore.purgeExpired() is the seam: it deletes every row past its expiry and returns the count removed. Wire it to a scheduler job. deleteForUser(userId) is the other cleanup entry point — called automatically on every successful consume, and worth calling on account closure.

The memory store needs neither: it evicts on read, caps at maxEntries (default 10_000, oldest-first), and can sweep periodically via evictionIntervalMs.