npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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

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

  • 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> with protected 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_GUARD chain 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-graphql

Peer Dependencies

npm install @nestjs/common @nestjs/core @nestjs/graphql @nestjs/jwt @nestjs/passport @nestjs/throttler graphql passport passport-jwt reflect-metadata rxjs

resend 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 Authorization header (Bearer/API key)
  • The route has @Public()
  • Cookie auth is not enabled
  • requireInProduction is 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:

  1. AUTH_IP_HASH_SALT env var (recommended — dedicated, rotatable secret)
  2. options.jwtSecret (fallback; one-time INFO log when bruteForce.ipRateLimit is configured)
  3. Throws InvalidAuthConfigException if bruteForce.ipRateLimit is 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) -- Calls userRepository.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.