RFC‑compliant Multi‑Factor Authentication (2FA/MFA) module for NestJS, built on otplib. Pluggable storage, QR codes, recovery codes, and event‑driven architecture.
- Requirements and Peer Dependencies
- Features
- Installation
- Module Registration
- Usage
- QR Code
- Advanced Usage
- Events
- Compliance
- NodeJS (version 20 or above)
- Keyv
- Event Emitter
- NestJS (version 9 or above)
- Context-aware: HTTP, RPC, WebSocket, etc.
- Services: Registration, validation, QR generation, recovery
- Security: Encrypted storage
- Extensible: Pluggable extractors, decorators, and event hooks
- Optimized: Streamable QR codes, low memory footprint
npm i -S @enteocode/nestjs-mfa
Configure using forRoot
or forRootAsync
, according to NestJS standards:
app.module.ts
import { Module } from '@nestjs/common';
import { EventEmitterModule } from '@nestjs/event-emitter';
import { MfaModule } from '../src';
import { createKeyv } from '@keyv/redis';
@Module({
imports: [
// Required for events
EventEmitterModule.forRoot(),
MfaModule.forRoot({
// Displayed in authenticator apps
issuer: 'My Application',
// Persistent key-value adapter for KeyV
store: createKeyv(/* ... */),
// AES-256-GCM
cipher: 'my-32-byte-encryption-key'
})
]
})
export class AppModule {}
Important: The
EventEmitterModule
must be registered.
Name | Type | Required | Default | Description |
---|---|---|---|---|
issuer |
string |
Yes | – | App name for authenticator display |
store |
Keyv |
Yes | – | Persistent key-value store |
ttl |
number |
No | 30 |
TOTP token lifetime (seconds) |
serializer |
SerializerInterface |
No | V8 |
Data serialization (JSON, V8, etc.) |
cipher |
string |
No | – | AES-256 key for encrypted storage |
Hint: Provide a
cipher
to protect user secrets according to NIS2 and GDPR standards
Persistent storage is required.
Supported Keyv adapters: Etcd, Memcache, Mongo, MySQL, Postrgres, Redis, SQLite, Valkey.
After registration, you can use the MfaService
to implement it to your setup by injecting it to your service or
controller.
mfa.controller.ts
import { Body, Controller, InternalServerErrorException, ParseUUIDPipe, Post, StreamableFile } from '@nestjs/common';
import { Format, MfaService, TokenType } from '@enteocode/nestjs-mfa';
import { TokenVerificationRequest } from './mfa.token-verification.request.ts';
@Controller('mfa')
class MfaController {
constructor(private readonly mfa: MfaService) {}
@Post('enable/:user')
public async enable(@Param('user', ParseUUIDPipe) user: string): Promise<StreamableFile> {
const secret = await this.mfa.enable(user);
if (!secret) {
throw new InternalServerErrorException('Cannot enable MFA for user');
}
return this.mfa.generate(user, TokenType.AUTHENTICATOR, Format.PNG);
}
@Post('verify')
public async verify(@Body() { user, token }: TokenVerificationRequest): Promise<boolean> {
return this.mfa.verify(user, token);
}
}
token-verification.request.ts
import { IsUUID } from 'class-validator';
import { IsToken, Token } from '@enteocode/nestjs-mfa';
// ValidationPipe based flow
export class TokenVerificationRequest {
@IsUUID()
user: string;
@IsToken() // Built-in validator
token: Token;
}
mfa.controller.ts
import { Controller } from '@nestjs/common';
import { EventPattern, Payload } from '@nestjs/microservices';
@Controller()
class MfaController {
constructor(private readonly mfa: MfaService) {}
@MessagePattern({ cmd: 'mfa.enable '})
public async enable(@Payload() user: string): Promise<string> {
/* ... */
}
}
The QR code contains a Key URI, originally defined by Google for its Authenticator app and later adopted by other authenticator applications. This URI encodes the secret along with key properties (such as account name and issuer) and is used to register the authenticator with a user’s device.
Conceptually, it's similar to asymmetric cryptography: the secret acts like a private key, and the app uses it to generate public one-time codes.
You can choose to generate the QR code on the client side or the backend side:
- Client-side generation reduces backend load and cost.
- Backend-side generation is handled by the module using streamed rendering to keep memory and CPU usage minimal.
QR codes are only required for the initial pairing with the authenticator app.
mfa.authenticator.controller.ts
import { Controller, Get, Param, ParseUUIDPipe, StreamableFile } from '@nestjs/common';
import { MfaService, TokenType } from '@enteocode/nestjs-mfa';
@Controller('mfa/generate')
class MfaAuthenticatorController {
constructor(private readonly mfa: MfaService) {}
@Get(':user/qr')
public generateQrCode(@Param('user', ParseUUIDPipe) user: string): Promise<StreamableFile> {
return this.mfa.generate(user, TokenType.AUTHENTICATOR, Format.WEBP)
}
@Get(':user/uri')
public generateKeyUri(@Param('user', ParseUUIDPipe) user: string): Promise<string> {
return this.mfa.generate(user, TokenType.AUTHENTICATOR)
}
@Get(':user/token')
public generateToken(@Param('user', ParseUUIDPipe) user: string): Promise<string> {
// Manual token creation instead of authenticator
// Useful to generate one-time password for email based second factor
return this.mfa.generate(user, TokenType.TIMEOUT, { step: 5 * 60, digits: 8 })
}
}
Format | Avg. Size | MIME Type |
---|---|---|
WebP | 242B | image/webp |
PNG | 406B | image/png |
AVIF | 674B | image/avif |
JPEG | 3.7KB | image/jpeg |
Note: Sizes measured with test data, actual results may vary
To avoid manually verifying tokens across multiple endpoints, you can implement credential extractors. These provide a context-specific mechanism for extracting credentials (such as user ID and token) and feeding them into a parameter decorator. This makes the solution reusable, streamlined, and transport-agnostic (HTTP, RPC, WebSockets, etc.).
Hint: you can create multiple extractors and the first one will be ued which supports the actual
ExecutionContext
.
An example of a HTTP extractor, that gets credentials from the header, with the pattern:
HTTP Header
X-MFA: otp://user:012345@authenticator
mfa.http.credentials-extractor.ts
import { ExecutionContext, Injectable } from '@nestjs/common';
import { FastifyRequest } from 'fastify';
import { MfaCredentialsExtractor, MfaCredentialsExtractorInterface } from '@enteocode/nestjs-mfa';
@MfaCredentialsExtractor('http')
@Injectable()
class MfaHttpCredentialsExtractor implements MfaCredentialsExtractorInterface {
public supports(context: ExecutionContext): boolean {
return Boolean(this.getHeader(context));
}
public getUserIdentifier(context: ExecutionContext): Identifier {
return URL.parse(this.getHeader(context)).username;
}
public getToken(context: ExecutionContext): Token {
return URL.parse(this.getHeader(context)).password;
}
// Helper
private getHeader(context: ExecutionContext, header: string = 'x-mfa'): string {
return context.switchToHttp().getRequest<FastifyRequest>().headers[header];
}
}
Hint: If you leave the
MfaCredentialsExtractor
decorator's parameter empty, it will be called to check support on every context.
You have to add this extractor to your list of providers to be part of the Dependency Injection container in order to
be detectable.
You are ready to use:
mfa.controller.ts
import { Controller, Get } from '@nestjs/common';
import { MfaCredentials, MfaCredentialsInterface } from '@enteocode/nestjs-mfa';
@Controller()
class MfaController {
@Get()
public async login(@MfaCredentials({ required: true, validate: true }) credentials: MfaCredentialsInterface) {
// credentials.user [Identifier] The unique identifier of the user (email, UUID, etc.)
// credentials.token [Token] The 6-digit token
}
}
Hint: All options are optional, credentials will be automatically validated if
validate
istrue
Event | Payload | Description |
---|---|---|
mfa.enabled |
{ user: string, secret: string } |
MFA activated for user |
mfa.disabled |
{ user: string } |
MFA deactivated |
mfa.failed |
{ user: string, token: string } |
Invalid token provided |
Event | Payload | Description |
---|---|---|
mfa.recovery.enabled |
{ user: string, codes: Set<string>} |
Backup codes generated |
mfa.recovery.disabled |
{ user: string } |
Backup codes deleted |
mfa.recovery.used |
{ user: string, code: string } |
Backup code consumed |
mfa.recovery.failed |
{ user: string, code: string } |
Backup code validation failed |
app.event-listener.ts
import { Injectable } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { AuthenticationFailedEvent, EventType } from '@enteocode/nestjs-mfa';
@Injectable()
class AppEventListener {
@OnEvent(EventType.AUTHENTICATION_FAILED)
onMfaAuthenticationFailed(event: AuthenticationFailedEvent) {
// Block user after 5 attempts
}
}
MIT © 2025, Ádám Székely