@ambushsoftworks/nestjs-auth-graphql
v0.8.0
Published
Production-grade authentication package for NestJS with GraphQL, supporting JWT, OAuth, email/SMS verification, and biometric auth
Maintainers
Readme
@ambushsoftworks/nestjs-auth-graphql
Production-grade authentication module for NestJS GraphQL + REST APIs. JWT, cookie auth, multi-tenancy, realm-based identity isolation, OAuth, email verification, and more -- with zero database coupling.
Table of Contents
- Design Principles
- Installation
- Quick Start
- Why BaseAuthResolver?
- Features
- Configuration Reference
- Decorators
- Guards
- Utilities
- Security Features
- License
Design Principles
- Interface-driven persistence -- All storage is behind interfaces (
IUserRepository,IRefreshTokenRepository,ITenantRepository, etc.). Bring your own database. - Instance-based DI -- Repositories and services are injected as instances via
useFactory, not as classes. - Consumer owns GraphQL types -- The package provides
BaseAuthResolver<T>withprotected perform*()methods. You create your own resolver with@Mutation()decorators (see Why BaseAuthResolver?). - Guards are exported, not auto-registered -- You register guards in your own
APP_GUARDchain to control execution order. - NoOp fallbacks -- Every optional dependency has a no-op implementation, so the module works with minimal config.
Installation
npm install @ambushsoftworks/nestjs-auth-graphqlPeer Dependencies
npm install @nestjs/common @nestjs/core @nestjs/graphql @nestjs/jwt @nestjs/passport @nestjs/throttler graphql passport passport-jwt reflect-metadata rxjsresend is an optional peer dependency -- install it only if using the Resend email sender.
Quick Start
Minimal setup: JWT authentication with email/password login.
1. Implement the required repositories
// users.repository.ts
import { Injectable } from '@nestjs/common';
import { IUserRepositoryCore, CreateUserData } from '@ambushsoftworks/nestjs-auth-graphql';
@Injectable()
export class UsersRepository implements IUserRepositoryCore<User> {
async findByEmail(email: string): Promise<User | null> { /* ... */ }
async findById(id: string): Promise<User | null> { /* ... */ }
async create(data: CreateUserData): Promise<User> { /* ... */ }
// ... implement remaining IUserRepositoryCore methods
}// refresh-token.repository.ts
import { Injectable } from '@nestjs/common';
import { IRefreshTokenRepository } from '@ambushsoftworks/nestjs-auth-graphql';
@Injectable()
export class RefreshTokenRepository implements IRefreshTokenRepository {
async create(data: {
userId: string;
token: string;
hashedToken: string;
expiresAt: Date;
deviceInfo?: string | null;
}): Promise<any> { /* ... */ }
async findByHashedToken(hashedToken: string): Promise<any | null> { /* ... */ }
async deleteByUserId(userId: string): Promise<number> { /* ... */ }
// ... implement remaining methods
}2. Register the module
import { Module } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { AuthModule } from '@ambushsoftworks/nestjs-auth-graphql';
@Module({
imports: [
AuthModule.forRootAsync({
imports: [ConfigModule, DatabaseModule],
inject: [UsersRepository, RefreshTokenRepository, ConfigService],
useFactory: (usersRepo, tokenRepo, config) => ({
userRepositoryInstance: usersRepo,
refreshTokenRepositoryInstance: tokenRepo,
jwtSecret: config.get('JWT_SECRET'),
}),
}),
],
providers: [UsersRepository, RefreshTokenRepository],
})
export class AppModule {}3. Create your auth resolver
import { Resolver, Mutation, Args, Context } from '@nestjs/graphql';
import { Inject } from '@nestjs/common';
import {
BaseAuthResolver,
CurrentUser,
AuthService,
BruteForceProtectionService,
USER_REPOSITORY,
AUTH_LOGGER,
} from '@ambushsoftworks/nestjs-auth-graphql';
@Resolver()
export class AuthResolver extends BaseAuthResolver<User> {
constructor(
authService: AuthService,
bruteForceProtection: BruteForceProtectionService,
@Inject(USER_REPOSITORY) userRepository: any,
@Inject(AUTH_LOGGER) authLogger: any,
) {
super(authService, bruteForceProtection, userRepository, authLogger);
}
@Mutation(() => AuthResponse)
async signup(@Args('input') input: SignupInput, @Context() ctx: any) {
return this.performSignup(input, ctx);
}
@Mutation(() => AuthResponse)
async login(@Args('input') input: LoginInput, @Context() ctx: any) {
return this.performLogin(input, ctx);
}
@Mutation(() => AuthResponse)
async refreshToken(@Args('input') input: RefreshTokenInput, @Context() ctx: any) {
return this.performRefreshToken(input, ctx);
}
@Mutation(() => LogoutResponse)
async logout(@Args('input') input: LogoutInput, @CurrentUser() user: User) {
return this.performLogout(input, user);
}
}4. Set up the guard chain
import { APP_GUARD } from '@nestjs/core';
import { createAuthGuard } from '@ambushsoftworks/nestjs-auth-graphql';
@Module({
providers: [
{
provide: APP_GUARD,
useClass: createAuthGuard(['jwt'], { allowPublic: true }),
},
],
})
export class AppModule {}Mark public routes with @Public():
import { Public } from '@ambushsoftworks/nestjs-auth-graphql';
@Public()
@Mutation(() => AuthResponse)
async login() { /* ... */ }Why BaseAuthResolver?
TypeScript decorator metadata is not preserved when importing from compiled npm packages. If the package exported a resolver with @Mutation(() => AuthResponse), NestJS would throw "Cannot determine GraphQL output type" at runtime.
BaseAuthResolver<T> solves this by providing only business logic via protected perform*() methods. You add the GraphQL decorators in your own code, where metadata resolution works correctly.
When cookie auth is enabled, pass the GraphQL context so tokens are set as HttpOnly cookies. The response body will contain empty strings for accessToken/refreshToken.
Reference template: examples/full-resolver-template.ts ships in the published package and covers every perform* method with copy-paste-ready @Mutation/@Query decorators, @Throttle on auth-sensitive endpoints, and @UseGuards(JwtAuthGuard) on authenticated routes. It also demonstrates the changePassword + issueAuthSession keep-alive pattern. Each perform* method on BaseAuthResolver also has a method-specific JSDoc snippet you can lift directly.
Available perform*() methods
Authentication:
| Method | Purpose |
|--------|---------|
| performSignup(input, context?) | Create account |
| performLogin(input, context) | Authenticate |
| performRefreshToken(input, context?) | Rotate tokens |
| performLogout(input, user, context?) | Invalidate token |
| performLogoutAll(user, context?) | Invalidate all tokens |
| performGetCurrentUser(userId) | Get current user |
Verification:
| Method | Purpose |
|--------|---------|
| performVerifyEmail(input, context?) | Email verification |
| performResendVerificationEmail(email) | Resend verification email |
| performSendPhoneVerification(input, user) | Send SMS verification code |
| performVerifyPhone(input, user) | Verify phone with SMS code |
| performResendPhoneVerification(phoneNumber, user) | Resend phone verification |
| performRemovePhoneNumber(user) | Remove phone number from account |
| performPhoneVerificationStatus(user) | Get phone verification status |
Password:
| Method | Purpose |
|--------|---------|
| performRequestPasswordReset(input, context?) | Send reset code/token |
| performResetPassword(input, context?) | Reset password |
| performChangePassword(userId, current, new) | Change password |
Other:
| Method | Purpose |
|--------|---------|
| performCheckAccountLockStatus(email) | Check brute force lock status |
| performCompleteFacebookSignUp(input) | Facebook email fallback |
Features
Cookie Authentication
Enable features.cookieAuth to deliver tokens via HttpOnly cookies instead of response bodies.
AuthModule.forRootAsync({
useFactory: () => ({
// ...required options
features: { cookieAuth: true },
cookie: {
httpOnly: true, // default
secure: true, // default
sameSite: 'lax', // default
domain: undefined, // browser uses request domain
useHostPrefix: true, // prefix names with __Host- for enhanced security
},
}),
})When useHostPrefix: true, cookie names become __Host-access_token and __Host-refresh_token. The module validates at startup that secure is true, path is /, and domain is unset (as required by the __Host- spec).
You can also customize cookie names and max ages -- see Cookie Options.
The JWT strategy automatically reads from cookies first, then falls back to Authorization: Bearer headers.
CSRF Protection
With cookie auth, register CsrfGuard to protect mutations. It validates that a configurable header (default: X-Requested-With) is present when the request is authenticated via cookies.
import { APP_GUARD } from '@nestjs/core';
import { createAuthGuard, CsrfGuard } from '@ambushsoftworks/nestjs-auth-graphql';
@Module({
providers: [
{ provide: APP_GUARD, useClass: createAuthGuard(['jwt'], { allowPublic: true }) },
{ provide: APP_GUARD, useClass: CsrfGuard },
],
})Configure via csrf options:
csrf: {
headerName: 'X-Requested-With', // default
requireInProduction: true, // default
exemptOperations: ['refreshToken'], // skip CSRF for specific operations
}The guard automatically skips when:
- The request uses
Authorizationheader (Bearer/API key) - The route has
@Public() - Cookie auth is not enabled
requireInProductionis false and not in production- The GraphQL operation is in
exemptOperations
Multi-Tenancy
Enable tenant resolution and permission checking across requests.
AuthModule.forRootAsync({
useFactory: (tenantRepo, tenantExtractor) => ({
// ...required options
features: { multiTenancy: true },
tenantRepositoryInstance: tenantRepo,
tenantExtractorInstance: tenantExtractor,
}),
})Guard chain (order matters):
providers: [
{ provide: APP_GUARD, useClass: createAuthGuard(['jwt'], { allowPublic: true }) },
{ provide: APP_GUARD, useClass: TenantGuard },
{ provide: APP_GUARD, useClass: PermissionGuard },
]ITenantRepository resolves whether a user has access to a tenant:
@Injectable()
class TenantRepository implements ITenantRepository {
async resolveTenant(userId: string, tenantId: string): Promise<ITenantContext | null> {
const membership = await this.db.membership.find({ userId, tenantId });
if (!membership) return null;
return {
tenantId,
permissions: membership.role.permissions,
metadata: { accountId: membership.accountId },
};
}
}ITenantExtractor pulls the tenant ID from the request. Use the built-in HeaderTenantExtractor (reads x-tenant-id header) or implement your own:
import { HeaderTenantExtractor } from '@ambushsoftworks/nestjs-auth-graphql';
// Default: reads x-tenant-id header
const extractor = new HeaderTenantExtractor();
// Custom header name
const extractor = new HeaderTenantExtractor('x-org-id');extractTenantId may also return Promise<string | null>. TenantGuard awaits the result with no microtask overhead for sync returns. Async throws and rejected Promises are treated identically to a sync null return — the request is rejected with 400 Bad Request and the internal cause is logged via Nest's Logger (never leaked to the client).
Access tenant context in resolvers:
@Query(() => [Item])
async items(@CurrentTenant() tenant: ITenantContext) {
return this.service.findByTenant(tenant.tenantId);
}Skip tenant resolution for routes that don't need it:
@SkipTenant()
@Query(() => User)
async me(@CurrentUser() user: User) { /* ... */ }Permission checking with @RequirePermissions():
@RequirePermissions('clients:read')
@Query(() => [Client])
async clients(@CurrentTenant() tenant: ITenantContext) { /* ... */ }For resource-scoped permissions, combine with @ResourceScope() and provide an IResourcePermissionRepository:
@RequirePermissions('clients:write')
@ResourceScope('client', 'clientId')
@Mutation(() => Client)
async updateClient(@Args('clientId') clientId: string) { /* ... */ }Realm-Based Identity Isolation
Realms partition the user identity space so that users in one realm are completely invisible to another. The same email address can exist independently in different realms, each with its own password, tokens, and verification state. This is useful for white-label platforms, multi-brand businesses, and franchise systems.
Realms are opt-in: provide a realmExtractorInstance in AuthModuleOptions. No separate feature flag is needed. When not provided, everything works exactly as before.
Implement IRealmExtractor:
import { Injectable } from '@nestjs/common';
import { IRealmExtractor } from '@ambushsoftworks/nestjs-auth-graphql';
@Injectable()
export class HostnameRealmExtractor implements IRealmExtractor {
extractRealm(request: Record<string, any>): string | null {
const host = request.headers?.host;
if (!host) return null;
// e.g., "acme.example.com" -> "acme"
const subdomain = host.split('.')[0];
return subdomain || null;
}
}Async extractor (DB-backed lookup): extractRealm may also return Promise<string | null>. RealmMiddleware awaits the result with no microtask overhead for sync returns (instanceof Promise gate). Async throws and rejected Promises are treated identically to a sync null return — the request is rejected with 400 Bad Request and the internal cause is logged via Nest's Logger (never leaked to the client). The same widening applies to ITenantExtractor.extractTenantId.
@Injectable()
export class DbRealmExtractor implements IRealmExtractor {
constructor(private readonly prisma: PrismaService) {}
async extractRealm(request: Record<string, any>): Promise<string | null> {
const host = request.headers?.host;
if (!host) return null;
const site = await this.prisma.site.findUnique({ where: { hostname: host } });
return site ? `site:${site.slug}` : null;
}
}Register the module with realm support:
AuthModule.forRootAsync({
imports: [ConfigModule, DatabaseModule],
inject: [UsersRepository, RefreshTokenRepository, HostnameRealmExtractor, ConfigService],
useFactory: (usersRepo, tokenRepo, realmExtractor, config) => ({
userRepositoryInstance: usersRepo,
refreshTokenRepositoryInstance: tokenRepo,
jwtSecret: config.get('JWT_SECRET'),
realmExtractorInstance: realmExtractor,
}),
})RealmMiddleware is automatically registered by AuthModule — no manual middleware registration needed. When a realmExtractorInstance is provided, the middleware calls extractor.extractRealm(request) on every request before any guards run. If the extractor returns null, the request is rejected with 400 Bad Request (strict mode).
Use @CurrentRealm() in resolvers:
import { CurrentRealm } from '@ambushsoftworks/nestjs-auth-graphql';
@Resolver()
export class AuthResolver extends BaseAuthResolver<User> {
@Mutation(() => AuthResponse)
async signup(
@Args('input') input: SignupInput,
@CurrentRealm() realm: string,
@Context() ctx: any,
) {
return this.performSignup(input, ctx);
}
}BaseAuthResolver automatically extracts the realm from the GraphQL context via getRealmFromContext() and passes it to all service methods. Repository identity-resolution methods (findByEmail, findByPhoneNumber, findByOAuthProvider, create) receive the realm as a parameter so your implementation can scope queries accordingly.
JWT realm validation: The JWT payload includes a realm claim. On subsequent requests, JwtStrategy validates that the realm in the token matches the realm resolved from the current request. A mismatch results in 401 Unauthorized.
Rate limiter scoping: When realms are enabled, rate limiter keys are automatically prefixed with the realm to prevent cross-realm interference (e.g., a lockout in realm A does not affect realm B).
Realms vs. Multi-Tenancy:
| Concern | Realms | Multi-Tenancy |
|---------|--------|---------------|
| Partitions | Identity (who the user is) | Authorization (what the user can access) |
| Scope | User signup, login, tokens, verification | Permissions, resources, team membership |
| Same email in multiple? | Yes -- fully independent users | Yes -- same user, different tenant contexts |
| Guard layer | Middleware (before guards) | Guard (TenantGuard, after auth) |
Realms and multi-tenancy are orthogonal and can be used together. For example, a white-label SaaS platform might use realms to isolate brand identities and tenancy to manage organizations within each brand.
Note: Deprecated OAuth controller methods are not realm-aware. Use the current BaseAuthResolver OAuth methods for realm-scoped OAuth flows.
API Key Authentication
For machine-to-machine auth, provide an IApiKeyRepository:
AuthModule.forRootAsync({
useFactory: (apiKeyRepo) => ({
// ...required options
apiKeyRepositoryInstance: apiKeyRepo,
}),
})Then use a multi-strategy guard:
{ provide: APP_GUARD, useClass: createAuthGuard(['api-key', 'jwt'], { allowPublic: true }) }The ApiKeyStrategy hashes the bearer token with SHA-256 and calls findByKeyHash() on your repository. API keys and JWT tokens use the same Authorization: Bearer header -- strategies are tried in the order specified. When no bearer token is present (e.g. cookie auth), the API key strategy gracefully falls through to the next strategy.
Email System
The email system uses a composable architecture: a sender (transport) and a template renderer (HTML generation).
import { SendGridEmailSender } from '@ambushsoftworks/nestjs-auth-graphql';
AuthModule.forRootAsync({
useFactory: (config) => ({
// ...required options
email: {
sender: new SendGridEmailSender(config.get('SENDGRID_API_KEY')),
from: { email: '[email protected]', name: 'MyApp' },
branding: {
appName: 'MyApp',
primaryColor: '#1976D2',
logoUrl: 'https://example.com/logo.png',
companyName: 'My Company',
supportEmail: '[email protected]',
},
},
}),
})email.branding is required when using the default template renderer. If you provide a custom email.templateRenderer, branding can be omitted.
Built-in senders: SendGridEmailSender, ResendEmailSender, NoOpEmailSender
Custom sender: Implement IEmailSender:
class SmtpEmailSender implements IEmailSender {
async send(params: {
to: string;
from: { email: string; name?: string };
subject: string;
html: string;
text?: string;
}) {
// your SMTP logic
}
}Custom template renderer: Override DefaultEmailTemplateRenderer by providing email.templateRenderer:
email: {
sender: new SendGridEmailSender(apiKey),
from: { email: '[email protected]' },
templateRenderer: new MyCustomRenderer(),
}Verification Modes
Two modes for email verification and password reset:
'code'(default) -- 6-digit numeric codes'token'-- URL-based tokens with configurable base URL
features: { verificationMode: 'token' },
verification: {
baseUrl: 'https://app.example.com',
tokenLength: 64, // bytes, default: 64
tokenExpiresInMinutes: 60, // default: 60
},OAuth
Google and Facebook OAuth with encrypted token storage (AES-256-GCM).
AuthModule.forRootAsync({
useFactory: (config) => ({
// ...required options
encryptionKey: config.get('ENCRYPTION_KEY'), // 32-byte hex string
oauth: {
google: {
clientId: config.get('GOOGLE_CLIENT_ID'),
clientSecret: config.get('GOOGLE_CLIENT_SECRET'),
callbackUrl: config.get('GOOGLE_CALLBACK_URL'),
},
facebook: {
clientId: config.get('FACEBOOK_CLIENT_ID'),
clientSecret: config.get('FACEBOOK_CLIENT_SECRET'),
callbackUrl: config.get('FACEBOOK_CALLBACK_URL'),
},
},
}),
})The OAuthController is automatically registered and handles OAuth callback routes. OAuth strategies gracefully degrade to no-ops when credentials are not configured.
Brute Force Protection
When features.bruteForceProtection is enabled and a bruteForceRepositoryInstance is provided, the module tracks failed login attempts and temporarily locks accounts. The lockout policy (attempt thresholds, lockout duration) is determined by your IBruteForceRepository implementation.
IP Rate Limiting
BruteForceProtectionService.checkIpRateLimit(ipAddress, realm?) enforces a sliding-window rate limit per IP. Opt in by setting bruteForce.ipRateLimit:
import { RedisRateLimiter } from './redis-rate-limiter'; // your implementation
AuthModule.forRootAsync({
inject: [RedisRateLimiter],
useFactory: (redisRateLimiter) => ({
// ...required options
rateLimiterInstance: redisRateLimiter, // REQUIRED with ipRateLimit
bruteForce: {
ipRateLimit: { maxAttempts: 10, windowMs: 60_000 }, // 10 per minute per IP
},
}),
})When bruteForce.ipRateLimit is configured, AuthModule.forRootAsync requires an explicit rateLimiterInstance and throws InvalidAuthConfigException at boot otherwise:
[AuthModule] bruteForce.ipRateLimit is configured but rateLimiterInstance is undefined.
The default InMemoryRateLimiterService is unsafe for multi-replica deployments
(per-replica counters do not coordinate) and is not auto-applied for IP rate limiting.
Provide rateLimiterInstance explicitly: a Redis-backed implementation for production,
or `new InMemoryRateLimiterService()` for single-replica deployments / dev.
To disable IP rate limiting, remove the bruteForce.ipRateLimit slot.checkIpRateLimit reuses the same IRateLimiter infrastructure as password-reset throttling — implement once, reuse everywhere. For multi-replica production, back it with Redis (the same pattern documented for the RefreshTokenService cache applies). For single-replica or dev, new InMemoryRateLimiterService() is acceptable.
Threshold is inclusive: with maxAttempts: 5, the user is blocked starting on the 5th attempt within the window (record-then-check ordering means attempts >= maxAttempts returns true). Tune your config accordingly — maxAttempts: 5 is the first blocked attempt, not the first allowed-over.
When realms are enabled, rate-limit keys are namespaced as auth:ipRate:<realm>:<ipHash> so a single shared store serves many realms without collisions. IPs are hashed via hashIp(ip, salt) (HMAC-SHA256, 16-char hex prefix) before becoming keys; raw IPs are never written to the store or to logs. The salt is resolved once at service construction with the following priority:
AUTH_IP_HASH_SALTenv var (recommended — dedicated, rotatable secret)options.jwtSecret(fallback; one-time INFO log whenbruteForce.ipRateLimitis configured)- Throws
InvalidAuthConfigExceptionifbruteForce.ipRateLimitis configured but neither salt source is available
Note: the salt is read from AuthModuleOptions (not from process.env.JWT_SECRET directly), so consumers wiring their JWT secret via @nestjs/config / a secrets manager work correctly without populating process.env.JWT_SECRET.
When the limit is exceeded, SecurityEvent.IP_RATE_LIMIT_EXCEEDED is logged. The method returns boolean and never throws — callers branch and produce their own error envelope (typically HTTP 429).
Brute-Force Cleanup
BruteForceProtectionService.cleanupOldAttempts(olderThan: Date): Promise<number> deletes failed-attempt records older than the cutoff and returns the deletion count. The package does NOT ship a scheduler — wire one in your application:
import { Injectable, Logger } from '@nestjs/common';
import { Cron } from '@nestjs/schedule';
import { BruteForceProtectionService } from '@ambushsoftworks/nestjs-auth-graphql';
@Injectable()
export class BruteForceCleanupJob {
private readonly logger = new Logger(BruteForceCleanupJob.name);
constructor(private readonly bruteForce: BruteForceProtectionService) {}
@Cron('0 3 * * *') // daily at 03:00
async cleanup() {
const ninetyDaysAgo = new Date(Date.now() - 90 * 24 * 60 * 60 * 1000);
const deleted = await this.bruteForce.cleanupOldAttempts(ninetyDaysAgo);
this.logger.log(`Cleaned ${deleted} old brute-force-attempt rows`);
}
}This requires IBruteForceRepository.deleteOlderThan(beforeDate: Date): Promise<number> on your repository implementation. Reference Prisma implementation: return (await this.prisma.bruteForceAttempt.deleteMany({ where: { createdAt: { lt: beforeDate } } })).count;. Multi-replica deployments should coordinate at the cron layer (advisory lock, leader election) or accept the duplicate-delete cost — DELETE WHERE createdAt < ? is idempotent.
Stay Logged In After Password Change
changePassword revokes every refresh token for the user (logging out all devices). To keep the current device logged in while logging out the others, compose changePassword with issueAuthSession:
@Mutation(() => ChangePasswordResponse)
@UseGuards(JwtAuthGuard)
async changePassword(
@CurrentUser() user: User,
@Args('input') input: ChangePasswordInput,
@Context() ctx: any,
): Promise<ChangePasswordResponse> {
const result = await this.performChangePassword(
user.id,
input.currentPassword,
input.newPassword,
);
// Mint a fresh session for THIS device. Other devices are already revoked.
// Use the inherited `getResponseFromContext` helper so cookies work under
// both Apollo (`ctx.res`) and Express/Fastify (`ctx.req.res`).
const res = this.getResponseFromContext(ctx);
const tokens = await this.authService.issueAuthSession(user.id, res);
return { ...result, accessToken: tokens.accessToken, refreshToken: tokens.refreshToken };
}issueAuthSession(userId, res?, realm?) mints a new access + refresh pair, persists the refresh token, and (when features.cookieAuth is enabled and res is supplied) sets the auth cookies on the response. The caller is responsible for verifying the user's identity — issueAuthSession takes a userId and trusts it. Pair it only with flows that have already authenticated the user (changePassword, MFA enrollment completion, admin-impersonation reissue, etc.). Emits SecurityEvent.SESSION_ISSUED.
Password Policy
passwordPolicy: {
minLength: 12, // default: 8
maxLength: 72, // bcrypt limit
requireUppercase: true, // default: true
requireLowercase: true, // default: true
requireNumber: true, // default: true
requireSpecialChar: true, // default: false
customValidator: async (password) => {
// Optional: dictionary checks, leaked password detection, etc.
const isCommon = await checkLeakedPasswords(password);
return {
isValid: !isCommon,
errors: isCommon ? ['Password found in data breaches'] : [],
};
},
}Lifecycle Hooks
React to auth events without modifying core logic:
lifecycleHooksInstance: {
async onSignup(user) { /* create default settings, send analytics */ },
async onLogin(user) { /* update metrics */ },
async onLogout(user) { /* cleanup */ },
async onEmailVerified(user) { /* unlock features */ },
async onPhoneVerified(user) { /* enable 2FA */ },
async onOAuthAccountLinked(user, provider) { /* sync data */ },
async onPasswordReset(user) { /* send security alert */ },
async onPasswordChanged(user) { /* notify user */ },
async onAccountDelete(user) { /* cleanup user data */ },
onAuthFailure(email, ip, reason) { /* security monitoring */ },
}All hooks are optional. Async hooks are awaited but failures are logged and do not block the auth operation. onAuthFailure is synchronous (fire-and-forget).
JWT Validation Modes
'full'(default) -- CallsuserRepository.findById()on every request to verify the user still exists.'payload-only'-- Returns{ id, email }from the JWT payload without a DB lookup. Faster, but does not detect deleted/disabled users until token expiry.
jwtValidation: 'payload-only',JWT Payload Factory
Customize JWT claims by providing an IJwtPayloadFactory:
jwtPayloadFactoryInstance: {
createPayload(user) {
return { sub: user.id, email: user.email, role: user.role };
},
extractUserId(payload) {
return payload.sub;
},
}Configuration Reference
Required Options
| Option | Type | Description |
|--------|------|-------------|
| userRepositoryInstance | IUserRepository | User persistence |
| refreshTokenRepositoryInstance | IRefreshTokenRepository | Token persistence |
| jwtSecret | string | JWT signing secret |
Common Options
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| jwtExpiresIn | string | '15m' | Access token TTL |
| refreshTokenExpiresIn | string | '30d' | Refresh token TTL |
| jwtValidation | 'full' \| 'payload-only' | 'full' | JWT validation strategy |
| bcryptRounds | number | 12 | Password hashing cost |
| nodeEnv | string | 'production' | Environment identifier |
| passwordPolicy | PasswordPolicyConfig | See Password Policy | Password strength rules |
| encryptionKey | string | -- | 32-byte hex string for AES-256-GCM (OAuth token encryption) |
| bruteForce.ipRateLimit | { maxAttempts, windowMs } | -- | IP rate-limit policy for checkIpRateLimit. When set, rateLimiterInstance MUST also be provided explicitly (boot fails with InvalidAuthConfigException otherwise). See IP Rate Limiting. |
Feature Flags (features)
| Flag | Default | Description |
|------|---------|-------------|
| cookieAuth | false | Token delivery via HttpOnly cookies |
| multiTenancy | false | Enable TenantGuard + PermissionGuard |
| bruteForceProtection | false | Account lockout on failed logins |
| preventEnumerationOnSignup | false | Return synthetic success for duplicate emails |
| verificationMode | 'code' | 'code' for 6-digit codes, 'token' for URL tokens |
| emailVerification | false | Enable email verification flow |
| smsVerification | false | Enable SMS verification flow |
| googleOAuth | false | Enable Google OAuth |
| facebookOAuth | false | Enable Facebook OAuth |
| biometricAuth | false | Enable biometric authentication |
Security Secrets
Separate HMAC secrets for security isolation. All fall back to jwtSecret if not provided.
| Option | Type | Description |
|--------|------|-------------|
| refreshTokenSecret | string | HMAC secret for refresh token hashing |
| verificationCodeSecret | string | HMAC secret for verification code hashing |
| oauthStateSecret | string | JWT secret for OAuth CSRF state tokens |
| oauthRedirectWhitelist | string | Comma-separated allowed OAuth redirect URLs |
Cookie Options (cookie)
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| httpOnly | boolean | true | HttpOnly flag |
| secure | boolean | true | Secure flag |
| sameSite | 'lax' \| 'strict' \| 'none' | 'lax' | SameSite attribute |
| domain | string | undefined | Cookie domain |
| path | string | '/' | Cookie path |
| accessTokenName | string | 'access_token' | Access token cookie name |
| refreshTokenName | string | 'refresh_token' | Refresh token cookie name |
| accessTokenMaxAge | number | derived from jwtExpiresIn | Access token cookie max age (ms) |
| refreshTokenMaxAge | number | derived from refreshTokenExpiresIn | Refresh token cookie max age (ms) |
| useHostPrefix | boolean | false | Prefix names with __Host- |
CSRF Options (csrf)
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| headerName | string | 'X-Requested-With' | Header to check |
| requireInProduction | boolean | true | Require CSRF header in production |
| exemptOperations | string[] | [] | GraphQL operations to skip |
Composable Email (email)
| Option | Type | Description |
|--------|------|-------------|
| email.sender | IEmailSender | Transport (SendGrid, Resend, etc.) -- required |
| email.from | { email, name? } | Sender address -- required |
| email.branding | EmailBrandingConfig | App name, colors, logo, etc. -- required unless templateRenderer is provided |
| email.templateRenderer | IEmailTemplateRenderer | Override default HTML renderer |
When email is provided, it takes precedence over emailServiceInstance.
Verification Options (verification)
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| tokenLength | number | 64 | Token length in bytes (token mode) |
| tokenExpiresInMinutes | number | 60 | Token expiration (token mode) |
| baseUrl | string | -- | Base URL for verification/reset URLs |
Optional Instance Options
| Option | Interface | Fallback |
|--------|-----------|----------|
| emailServiceInstance | IEmailService | NoOpEmailService |
| smsServiceInstance | ISmsService | NoOpSmsService |
| lifecycleHooksInstance | IAuthLifecycleHooks | {} (no-op) |
| verificationRepositoryInstance | IVerificationRepository | NoOpVerificationRepository |
| bruteForceRepositoryInstance | IBruteForceRepository | NoOpBruteForceRepository |
| biometricRepositoryInstance | IBiometricRepository | NoOpBiometricRepository |
| authLoggerInstance | IAuthLogger | ConsoleAuthLogger |
| tenantRepositoryInstance | ITenantRepository | null |
| tenantExtractorInstance | ITenantExtractor | null |
| resourcePermissionRepositoryInstance | IResourcePermissionRepository | null |
| jwtPayloadFactoryInstance | IJwtPayloadFactory | DefaultJwtPayloadFactory |
| apiKeyRepositoryInstance | IApiKeyRepository | null |
| rateLimiterInstance | IRateLimiter | InMemoryRateLimiterService (Note: this fallback does NOT apply when bruteForce.ipRateLimit is configured — boot fails with InvalidAuthConfigException and an explicit rateLimiterInstance is required. See IP Rate Limiting above.) |
| realmExtractorInstance | IRealmExtractor | null (realms disabled) |
Decorators
| Decorator | Target | Description |
|-----------|--------|-------------|
| @Public() | Method/Class | Skip authentication |
| @PublicEndpoint() | Method/Class | Skip authentication + tenant resolution (combines @Public() + @SkipTenant()) |
| @SkipTenant() | Method/Class | Skip tenant resolution |
| @RequirePermissions('p1', 'p2') | Method/Class | Require specific permissions |
| @CurrentUser() | Parameter | Inject authenticated user |
| @CurrentTenant() | Parameter | Inject resolved tenant context |
| @CurrentRealm() | Parameter | Inject resolved realm from request context |
| @ResourceScope('resourceType', 'argName') | Method | Resource-level permission scoping |
Guards
| Guard | Description |
|-------|-------------|
| createAuthGuard(strategies, options) | Factory for multi-strategy auth guards |
| CsrfGuard | CSRF header validation for cookie auth |
| TenantGuard | Multi-tenant context resolution |
| PermissionGuard | Permission checking against tenant context |
| JwtAuthGuard | Basic JWT guard (prefer createAuthGuard) |
Recommended Guard Chains
When realm support is enabled, RealmMiddleware runs before all guards (as NestJS middleware) and attaches the resolved realm to request._realm. It is registered automatically by AuthModule -- see Realm-Based Identity Isolation.
// JWT only
{ provide: APP_GUARD, useClass: createAuthGuard(['jwt'], { allowPublic: true }) }
// JWT + API keys
{ provide: APP_GUARD, useClass: createAuthGuard(['jwt', 'api-key'], { allowPublic: true }) }
// Full stack with cookie auth + multi-tenancy
{ provide: APP_GUARD, useClass: CsrfGuard },
{ provide: APP_GUARD, useClass: createAuthGuard(['jwt', 'api-key'], { allowPublic: true }) },
{ provide: APP_GUARD, useClass: TenantGuard },
{ provide: APP_GUARD, useClass: PermissionGuard },Utilities
Helper functions exported for use in custom guards and middleware:
| Function | Description |
|----------|-------------|
| getRequestFromContext(context) | Extract request from ExecutionContext (handles GraphQL + HTTP) |
| extractIpAddress(request) | Extract client IP (checks clientIp, req.ip, falls back to 'unknown') |
import { getRequestFromContext, extractIpAddress } from '@ambushsoftworks/nestjs-auth-graphql';
@Injectable()
export class CustomGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const request = getRequestFromContext(context);
const ip = extractIpAddress(request);
// your logic
return true;
}
}Security Features
- Refresh token rotation with HMAC-SHA256 hashing and idempotent 10-second grace period
- Bcrypt password hashing with configurable rounds (default: 12)
- AES-256-GCM encryption for OAuth tokens at rest
- Constant-time comparison for verification codes
- Account enumeration prevention on signup (optional)
- Stateless CSRF protection for OAuth flows via
OAuthStateService __Host-cookie prefix support for enhanced cookie security- Separate HMAC secrets for refresh tokens, verification codes, and OAuth state
- Realm-based identity isolation with JWT realm claim validation and realm-scoped rate limiting
License
MIT -- see LICENSE.
