Skip to content

Email Verification Wiring

Recipes are working wiring examples with the sharp edges annotated. This page and the email-verification-wiring recipe 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-verificationEmailVerificationService with issue(username) → { token, userId, attributes } | undefined, findValid(token) (non-consuming "is this link still good?"), and consume(token) (atomic single-use: exactly one of two concurrent consumes wins; on success calls LocalUserStore.markEmailVerified(userId, now)). Tokens are 32 bytes of CSPRNG entropy; only the SHA-256 hash is stored — the plaintext exists solely in issue's return value. forRoot({ ttlMs }) sets the lifetime (default 24 h).
  • A token store driver@modularityjs/auth-local-email-verification-memory (bounded, maxEntries default 10 000 — the issuing endpoint is unauthenticated, so the store is capped) or ...-database-{typeorm,prisma} (persistent; TypeORM ships the email_verification_token entity, Prisma maps a model, delegate default emailVerificationToken).
  • @modularityjs/auth-local — supplies LocalUserStore (the markEmailVerified seam; the database user-store drivers write a timestamp column, emailVerifiedAtField default 'emailVerifiedAt') and requires a PasswordHasher driver (auth-local-scrypt or auth-local-bcrypt). It also imports: [AuthModule, CacheModule, EventsModule] — the cache backs its failed-login attempt counter and the event bus carries LocalAuthFailedEvent, so a cache driver and an events driver are boot requirements too, not optional extras. Omit either and boot fails with MJS0004 naming CacheService / 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.

typescript
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.