Email Verification
@modularityjs/auth-local-email-verification issues hashed, single-use, expiring email-verification tokens for auth-local users. The package owns the token lifecycle; the app owns the routes, the mail, and the throttling.
Flow
abstract class EmailVerificationTokenStore {
abstract issue(
tokenHash: string,
record: EmailVerificationRecord,
expiresAt: Date,
): Promise<void>;
abstract find(
tokenHash: string,
): Promise<EmailVerificationRecord | undefined>;
abstract consume(
tokenHash: string,
): Promise<EmailVerificationRecord | undefined>;
}EmailVerificationService 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 this return value. Unknown username returnsundefined(no throw).findValid(token)— non-consuming check: "is this link still good?" Useful for rendering a confirmation page before the POST that consumes.consume(token)— atomically deletes-and-returns the record (two concurrent consumes: exactly one winner), re-resolves the username the token was issued for, and only then callsmarkEmailVerified(userId, new Date()). Returnsundefinedfor invalid, expired, already-consumed tokens, and for a token whose address the account has since moved off — the token proves control of the address it was mailed to, not of whatever address the account holds now.EmailVerificationTokenStore.deleteForUser(userId)— revocation seam. Call it when an account changes its email, or a link already mailed to the previous address stays valid for the rest of its TTL.EmailVerificationTokenStore.purgeExpired()— expired records are never returned, but the database drivers do not delete them either. Sinceissueis usually reachable from an unauthenticated "resend verification" route, wire this to aschedulerjob or the table grows without bound. The memory store already evicts on read and atmaxEntries.
import { EmailVerificationService } from '@modularityjs/auth-local-email-verification';
import { Inject, Injectable } from '@modularityjs/di';
import { MailService } from '@modularityjs/mail';
@Injectable()
class RegistrationService {
constructor(
@Inject(EmailVerificationService)
private readonly verification: EmailVerificationService,
@Inject(MailService) private readonly mail: MailService,
) {}
async sendVerification(username: string): Promise<void> {
const issued = await this.verification.issue(username);
if (!issued) return; // unknown user — respond exactly as if sent
await this.mail.send({
to: username,
subject: 'Verify your email',
text: `https://example.com/verify-email?token=${issued.token}`,
});
}
async verify(token: string): Promise<boolean> {
const consumed = await this.verification.consume(token);
return consumed !== undefined; // user's emailVerifiedAt is now set
}
}The markEmailVerified seam
Consuming a token calls LocalUserStore.markEmailVerified(userId, verifiedAt) — an abstract method on the auth-local user store. Both database drivers implement it by writing a timestamp column (configurable, default emailVerifiedAt) on the user entity/model — so "is this account verified?" is a plain column check in your own user table, not a lookup in this package.
Configuration
AuthLocalEmailVerificationModule.forRoot({ ttlMs: 48 * 60 * 60 * 1000 });| Option | Default | Description |
|---|---|---|
ttlMs | 86_400_000 (24 h) | Token lifetime; minimum 1000 ms |
Token stores
Pick one store driver, mirroring the password-reset store family:
import { AuthLocalEmailVerificationModule } from '@modularityjs/auth-local-email-verification';
import { AuthLocalEmailVerificationMemoryModule } from '@modularityjs/auth-local-email-verification-memory';
const modules = [
// ...AuthModule, AuthLocalModule + hasher + user store...
AuthLocalEmailVerificationModule,
AuthLocalEmailVerificationMemoryModule,
];| Store | Notes |
|---|---|
@modularityjs/auth-local-email-verification-memory | Bounded (maxEntries default 10_000, oldest-first eviction — the issuing endpoint is unauthenticated, so the store is capped); optional evictionIntervalMs sweep |
@modularityjs/auth-local-email-verification-database-typeorm | email_verification_token table (EmailVerificationTokenRow entity ships with the package); consume races resolved by the delete's affected-row count |
@modularityjs/auth-local-email-verification-database-prisma | Prisma model (default delegate emailVerificationToken; .forRoot({ model }) to rename); columns tokenHash PK, userId, attributes, expiresAt (indexed) |
Stores never see plaintext — a leaked token table is unusable, since reversing SHA-256 over 256 bits of entropy is infeasible.
Enumeration safety
issue returns undefined for an unknown username instead of throwing, so the calling route can respond identically in both branches ("if that account exists, we've sent a link") — a different status or latency profile per branch would let an attacker probe which emails have accounts. Keep the route's response shape and status the same whether or not issued is defined.
Throttling stays at the route
The package deliberately does not rate-limit issue — the issuing endpoint is where abuse arrives, and that's an HTTP concern. Put http-rate-limit (or a RateLimiterService check keyed by email + IP) on the resend-verification route, exactly as you would for password reset.
See the Email verification wiring recipe for a complete bootable example.