Email Verification Wiring
Recipes are working wiring examples with the sharp edges annotated. This page and the
email-verification-wiringrecipe served to coding agents by the MCP server share one source (@modularityjs/manifest), so they never drift apart.
Three layers compose over auth-local:
@modularityjs/auth-local-email-verification—EmailVerificationServicewithissue(username) → { token, userId, attributes } | undefined,findValid(token)(non-consuming "is this link still good?"), andconsume(token)(atomic single-use: exactly one of two concurrent consumes wins; on success callsLocalUserStore.markEmailVerified(userId, now)). Tokens are 32 bytes of CSPRNG entropy; only the SHA-256 hash is stored — the plaintext exists solely inissue's return value.forRoot({ ttlMs })sets the lifetime (default 24 h).- A token store driver —
@modularityjs/auth-local-email-verification-memory(bounded,maxEntriesdefault 10 000 — the issuing endpoint is unauthenticated, so the store is capped) or...-database-{typeorm,prisma}(persistent; TypeORM ships theemail_verification_tokenentity, Prisma maps a model, delegate defaultemailVerificationToken). @modularityjs/auth-local— suppliesLocalUserStore(themarkEmailVerifiedseam; the database user-store drivers write a timestamp column,emailVerifiedAtFielddefault'emailVerifiedAt') and requires aPasswordHasherdriver (auth-local-scryptorauth-local-bcrypt). It alsoimports: [AuthModule, CacheModule, EventsModule]— the cache backs its failed-login attempt counter and the event bus carriesLocalAuthFailedEvent, so a cache driver and an events driver are boot requirements too, not optional extras. Omit either and boot fails withMJS0004namingCacheService/EventBus.
The package owns the token lifecycle only. The app owns the routes, the mail dispatch, and the throttling — send the link via MailService/NotificationService so the copy and URL shape are yours.
import { AuthModule } from '@modularityjs/auth';
import { AuthLocalModule, LocalUserStore } from '@modularityjs/auth-local';
import type { LocalUserRecord } from '@modularityjs/auth-local';
import {
AuthLocalEmailVerificationModule,
EmailVerificationService,
} from '@modularityjs/auth-local-email-verification';
import { AuthLocalEmailVerificationMemoryModule } from '@modularityjs/auth-local-email-verification-memory';
import { AuthLocalScryptModule } from '@modularityjs/auth-local-scrypt';
import { CacheModule } from '@modularityjs/cache';
import { CacheMemoryModule } from '@modularityjs/cache-memory';
import { Inject, Injectable } from '@modularityjs/di';
import { inversify } from '@modularityjs/di-inversify';
import { EventsModule } from '@modularityjs/events';
import { EventsMemoryModule } from '@modularityjs/events-memory';
import { createApp, Module, ModularityModule } from '@modularityjs/modularity';
@Injectable()
class DemoUserStore extends LocalUserStore {
private readonly verified = new Map<string, Date>();
async findByUsername(username: string): Promise<LocalUserRecord | undefined> {
if (username !== 'ada@example.com') return undefined;
return { id: 'user-1', passwordHash: 'scrypt-hash', attributes: {} };
}
async updatePassword(_userId: string, _passwordHash: string): Promise<void> {}
async markEmailVerified(userId: string, verifiedAt: Date): Promise<void> {
this.verified.set(userId, verifiedAt);
}
}
@Injectable()
class RegistrationService {
constructor(
@Inject(EmailVerificationService)
private readonly verification: EmailVerificationService,
) {}
async sendVerification(email: string): Promise<void> {
const issued = await this.verification.issue(email);
if (!issued) return; // unknown user — respond exactly as if sent
// mail the link: `https://app.example.com/verify-email?token=${issued.token}`
}
async verify(token: string): Promise<boolean> {
const consumed = await this.verification.consume(token);
return consumed !== undefined; // emailVerifiedAt is now set on the user
}
}
@Module({
name: 'registration',
imports: [AuthLocalModule, AuthLocalEmailVerificationModule],
providers: [DemoUserStore, RegistrationService],
preferences: [{ provide: LocalUserStore, useClass: DemoUserStore }],
})
class RegistrationModule {}
const app = await createApp({
di: inversify,
modules: [
ModularityModule,
// auth-local imports CacheModule + EventsModule, so both contracts and a
// driver for each have to be wired — swap in the redis drivers in prod.
CacheModule,
CacheMemoryModule,
EventsModule,
EventsMemoryModule,
AuthModule,
AuthLocalModule,
AuthLocalScryptModule,
AuthLocalEmailVerificationModule,
AuthLocalEmailVerificationMemoryModule,
RegistrationModule,
],
});Enumeration safety is a two-party contract. 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 probe which emails have accounts — keep the response shape the same whether or not issued is defined.
Throttling stays at the route. The package deliberately does not rate-limit issue — put @modularityjs/http-rate-limit (or a RateLimiterService check keyed by email + IP) on the resend-verification endpoint, exactly as for password reset.
Store switch is one line. Replace AuthLocalEmailVerificationMemoryModule with AuthLocalEmailVerificationDatabaseTypeormModule (next to your database wiring) or AuthLocalEmailVerificationDatabasePrismaModule — the service and routes are unchanged. In production, pair a database token store with the matching auth-local-database-* user store so markEmailVerified lands in your real user table.
Revoke on email change. EmailVerificationTokenStore.deleteForUser(userId) drops every outstanding token for an account. consume additionally re-resolves the username the token was issued for and refuses the token if it no longer maps to that user, so a link mailed to a previous address can never verify a new one — but the pending token still exists until you revoke it.
consume once, findValid freely. Render the "confirm your email" page with findValid (GET must be side-effect free) and call consume from the POST — a mail scanner prefetching the link must not burn the token.