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
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 viaLocalUserStore, generates 32 bytes of CSPRNG entropy (base64url), stores only its SHA-256 hash withexpiresAt = now + ttlMs, and returns{ token, userId, attributes }. The plaintext token exists only in that return value — it is never persisted. An unknown username returnsundefinedrather 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), hashesnewPasswordthrough the wiredPasswordHasher, writes it viaLocalUserStore.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.
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
AuthLocalPasswordResetModule.forRoot({ ttlMs: 30 * 60 * 1000 });| Option | Default | Description |
|---|---|---|
ttlMs | 3_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:
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,
];| Store | Notes |
|---|---|
@modularityjs/auth-local-password-reset-memory | Bounded (maxEntries default 10_000, oldest-first eviction — the issuing route is unauthenticated); optional evictionIntervalMs sweep via expiry |
@modularityjs/auth-local-password-reset-database-typeorm | password_reset_token table (entity ships with the package); consume races resolved by the delete's affected-row count |
@modularityjs/auth-local-password-reset-database-prisma | Prisma 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:
| Route | Purpose |
|---|---|
GET /password/forgot | Renders the "enter your email" form |
POST /password/forgot | Issues a token and hands it to PasswordResetDeliverer |
GET /password/reset | Validates the token (non-consuming) and renders the new-password form |
POST /password/reset | Consumes 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 assumesmail.PasswordResetFormRenderer— the app implements the four views. Wiretemplate-httpif you want them rendered by the template engine.
HttpAuthLocalPasswordResetModule.forRoot({
baseUrl: 'https://app.example.com',
signInAfterReset: true,
});| Option | Default | Description |
|---|---|---|
baseUrl | — required | Absolute origin the reset link is built from; boot fails if unset |
signInAfterReset | true | Establish 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.