@ambushsoftworks/nestjs-auth-graphql
v0.19.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
- Cookie Authentication
- Multi-Tenancy
- Realm-Based Identity Isolation
- API Key Authentication
- External JWTs
- Email System
- Verification Modes
- Phone & SMS Verification
- OAuth
- Biometric Authentication
- Passkeys
- Step-Up Re-authentication
- Email Challenges
- Guest Sessions
- Brute Force Protection
- Password Reset
- Password Policy
- Lifecycle Hooks
- Account Status
- Refresh Retries
- JWT Validation Modes
- JWT Payload Factory
- Configuration Reference
- Required Options
- Module Options
- Common Options
- Feature Flags
- Security Secrets
- Cookie Options
- CSRF Options
- Composable Email
- Verification Options
- Biometric Options (
biometric) - Passkey Options (
passkey) - Step-Up Options (
stepUp) - Email Challenge Options (
emailChallenge) - Guest Session Options (
guestSession) - SendGrid Options (
sendgrid) - Twilio Options (
twilio) - Optional Instance Options
- Decorators
- Guards
- Utilities
- Security Features
- Upgrading from 0.9.x
- Migrating to v0.12.0
- Migrating to v0.11.0
- Migrating to v0.10.0
- Migrating to v0.9.0
- 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-graphqlRequires Node.js 18 or later. Passwords are hashed with the native bcrypt module, which ships prebuilt binaries for Linux (glibc and musl/Alpine; x64, arm64, arm), macOS, and Windows, so installing on those platforms needs no compiler toolchain.
Peer 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.
@simplewebauthn/server (^14, Node.js 20+) is an optional peer dependency -- install it only if using passkeys. Boot fails with the install command if passkeys are configured without it.
Supports NestJS 10 and NestJS 11. CI runs the unit and end-to-end suites on both: NestJS 10 with @nestjs/graphql 12, Express 4 and Apollo Server 4, and NestJS 11 with @nestjs/graphql 13, Express 5 and Apollo Server 5.
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,
@Context() ctx: any,
) {
return this.performLogout(input, user, ctx);
}
}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 |
Passkeys and step-up (see Passkeys):
| Method | Purpose |
|--------|---------|
| performGetStepUpMethods(user, context?) | Which re-authentication methods this account has |
| performRequestStepUpEmailCode(user, context?) | Email a 6-digit re-authentication code |
| performStepUpPasskeyOptions(user, context?) | Options for re-authenticating with a passkey |
| performReauthenticate(user, input, context?) | Prove identity again; returns a step-up token |
| performPasskeyRegistrationOptions(user, input, context?) | Options for adding a passkey (needs a step-up token) |
| performVerifyPasskeyRegistration(user, input, context?) | Store the new passkey |
| performPasskeyAuthenticationOptions(context?) | Options for signing in (public) |
| performVerifyPasskeyAuthentication(input, context?) | Sign in; same response as performLogin |
| performListPasskeys(user, context?) | The user's passkeys |
| performRenamePasskey(user, input, context?) | Rename one |
| performRemovePasskey(user, input, context?) | Remove one (refused for the last way in) |
| performStartPasskeySignup(input, context?) | Passwordless sign-up, step 1: email a code (public) |
| performVerifyPasskeySignupCode(input, context?) | Step 2: check the code, get passkey creation options and a verified signupId (public) |
| performPasskeySignupOptions(input, context?) | Fresh options for a verified sign-up, no code (public) |
| performCompletePasskeySignup(input, context?) | Step 3: create the account with its passkey and sign in (public) |
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. performRefreshToken and performLogout are symmetric: when cookieAuth is on, they accept an empty input.refreshToken and pull the token from the refresh cookie instead. Browser SPAs cannot read HttpOnly cookies, so they should send { refreshToken: "" } and rely on the credentialed request carrying the cookie. Your consumer DTO must allow the empty value — mark refreshToken @IsOptional() (or @IsString() without @IsNotEmpty()) in your RefreshTokenInput and LogoutInput when targeting browser cookie auth. Non-browser clients (mobile/CLI) keep passing the token explicitly and that path takes precedence.
Note: Reading cookies requires
cookie-parsermiddleware (or the Fastify cookie plugin) to populatereq.cookies. The JWT strategy depends on this already; the same setup serves refresh/logout.
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
// Providing the tenant repository is what enables tenancy; there is no flag.
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).
Routes without a realm. Health checks, inbound webhooks and public downloads have no realm by nature. Match them with realm.skip, and the middleware lets them through without calling the extractor:
AuthModule.forRootAsync({
// ...
useFactory: (usersRepo, tokenRepo, realmExtractor, config) => ({
// ...required options
realmExtractorInstance: realmExtractor,
realm: {
skip: (path) => path === '/health' || path.startsWith('/hooks/'),
},
}),
})pathis the path the client requested, including any global prefix and without the query string. Match on it, not onrequest.pathorrequest.url: Express rewrites both to/inside middleware. The request itself is the second argument.- A skipped request has no realm:
request._realmstays undefined. - Skip only routes that are unauthenticated or authenticate by other means. A JWT's realm claim cannot match a request with no realm, so
JwtStrategyanswers 401.BaseAuthResolverflows and the OAuth routes answer400 Realm requiredrather than run outside a realm, which also contains a predicate that matches more than you meant. skipmust return a boolean synchronously. If it throws or returns anything else, such as the Promise from anasyncfunction, the request is not skipped and the error is logged.- Without
realmExtractorInstance,realm.skipis ignored and a warning is logged at boot. Arealm.skipthat is not a function fails boot withInvalidAuthConfigException.
Why a predicate rather than route patterns: the same Nest route pattern matches different paths on NestJS 10 and NestJS 11 — hooks/* excludes /hooks/glitchtip/issue on 11 but not on 10 — so a pattern list would behave differently depending on your Nest version. A @SkipRealm() decorator cannot work either, because middleware runs before Nest resolves the route handler.
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 JWTs share the Authorization: Bearer header, and strategies are tried in the order listed. A request with no bearer token, or with one that matches no key, falls through to the next strategy, so ['api-key', 'jwt'] and ['jwt', 'api-key'] both accept either credential. A key that matches an inactive account is rejected with 401 and no other strategy runs. When every strategy fails, the guard answers 401.
Before 0.10.0 the API key strategy rejected an unknown bearer token itself, which stopped the chain: ['api-key', 'jwt'] rejected every JWT.
Key options
| Option | Example | Effect |
|--------|---------|--------|
| apiKey.headerName | 'X-API-Key' | A header carrying the raw key, read before Authorization: Bearer, which keeps working. CsrfGuard treats a request carrying it as credentialed, as it already does for Authorization |
| apiKey.prefix | 'ait_' | The prefix every key you issue starts with. See below |
Both are read only with apiKeyRepositoryInstance, and apiKey.prefix without it warns at boot. headerName must be a header name other than Authorization, and prefix must not be empty -- an empty one matches every token, so every unknown bearer token, JWTs included, would be refused. Boot fails on either.
A prefix makes an unknown key unambiguous. Without one, the strategy cannot tell a mistyped key from a JWT:
| Token | Without prefix | With prefix |
|-------|------------------|---------------|
| Does not start with the prefix (a JWT, say) | Looked up, then passed to the next strategy | Passed to the next strategy, with no lookup |
| Starts with it, matches no key | Passed to the next strategy; a single-strategy guard answers a generic 401 | 401 Invalid API key -- it cannot be a JWT, so no other strategy tries it |
| Matches a key | Authenticated, unless the key is inactive or expired | The same |
Expiry, scopes and last use
IApiKeyAccount carries optional scopes?: string[] and expiresAt?: Date | null, and IApiKeyRepository may implement touchLastUsed?(id). Existing repositories keep working.
Expiry. A key whose
expiresAthas passed is refused with401 API key has expired, and no other strategy tries it. A key without one never expires.Scopes. Mark the operation and register
ScopeGuardafter your authentication guard:@Get('tickets') @UseGuards(createAuthGuard(['api-key', 'jwt']), ScopeGuard) @RequireScopes('tickets:read') listTickets() { /* ... */ }A key must hold every listed scope. One that does not gets 403 with
{ message: 'Insufficient scope', code: 'INSUFFICIENT_SCOPE', requiredScopes, statusCode: 403 }, naming what the operation requires. A key with noscopesholds none. Signed-in people pass: scopes restrict keys, while a person's access isPermissionGuard's job. With no authenticated principal at all,ScopeGuardanswers 401 -- which is why it goes after the auth guard.Last use.
touchLastUsed(id)is called once per successful match, and only then -- never for a key that is refused. It is not awaited: a failure is logged and never fails the request.
External JWTs
createExternalJwtStrategy(name, options) verifies tokens another system signs -- each app's backend signing for its own users, say. It registers a separate Passport strategy under name, with no realm check and no user lookup, so the package's own jwt strategy is untouched.
export const CustomerJwtStrategy = createExternalJwtStrategy('customer-jwt', {
inject: [AppSecretsService], // optional; pass a plain options object instead
useFactory: (secrets: AppSecretsService) => ({
algorithms: ['HS256'], // required
audience: 'ambush-desk',
secretProvider: (kid) => secrets.findSecret(kid),
mapClaims: (claims) => ({ id: claims.sub, app: claims.app, segments: claims.segments }),
}),
});
// providers: [CustomerJwtStrategy]
// @UseGuards(createAuthGuard(['customer-jwt']))| Option | Default | Description |
|--------|---------|-------------|
| secretProvider(kid, request) | -- | The key that verifies a token: a shared secret for HS*, a PEM public key for RS*, PS* and ES*. May be async. null rejects the token; so does a throw, which is logged |
| algorithms | -- | Required. The algorithms the issuer signs with |
| issuer, audience | -- | Required iss / aud, when set |
| clockToleranceSec | 0 | Clock skew allowed on exp and nbf |
| maxTokenBytes | 8192 | A longer token is refused before anything parses it |
| mapClaims(payload) | the payload | Builds request.user, arrays and custom claims intact. null, undefined or a throw rejects the token |
| jwtFromRequest(request) | Authorization: Bearer | Where the token comes from |
Algorithms are pinned, one family at a time. Boot fails with InvalidAuthConfigException for missing or empty algorithms, for none, for an algorithm jsonwebtoken does not know, and for HMAC (HS*) mixed with asymmetric algorithms -- the mix that lets a public key be used as an HMAC secret. 'jwt' and 'api-key' are refused as names, since Passport would replace the package's own strategy.
Every rejection fails softly, so the strategy composes: createAuthGuard(['customer-jwt', 'jwt']) accepts either kind of token, in either order, and answers 401 when both fail.
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.
What the package sends. Exactly three emails, each fired directly by an auth flow the package owns:
| Email | Fired by |
|---|---|
| Verification | signup, resendVerificationEmail |
| Password reset | requestPasswordReset |
| Password changed | resetPassword, changePassword |
Everything else — welcome, account-locked, account-linked/unlinked,
login-from-new-device — is consumer-owned, wired from lifecycle
hooks. The package does not fire them, so it does not
declare them on IEmailService. The branded templates for several of them are
still available on IEmailTemplateRenderer (renderWelcomeEmail,
renderAccountLockedEmail, renderEmailChangedEmail), so a hook can render
with the package's styling and hand the result to your own IEmailSender.
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, entered by the user'token'-- high-entropy tokens delivered as a clickable link
features: { verificationMode: 'token' },
verification: {
baseUrl: 'https://app.example.com', // REQUIRED in token mode
tokenLength: 64, // bytes, default: 64
tokenExpiresInMinutes: 60, // default: 60
},| | 'code' | 'token' |
|---|---|---|
| Credential | 6-digit code | tokenLength-byte hex token |
| Delivered as | code in the email body | link, no readable code |
| Default expiry | 15 minutes | 60 minutes |
| Hashing | HMAC-SHA256 | SHA-256 |
| Attempts before invalidation | 3 | 3 |
| verification.baseUrl | optional | required |
The modes differ in where the secret lives, not just in formatting. In token mode the credential is the link — it travels in the query string and the email body contains no readable code. In code mode the credential travels in the body and the link is a bare page URL carrying no secret at all, so it never reaches browser history, referrer headers, or proxy logs. This is why code mode does not simply put the 6-digit code into the URL, and why the package refuses to render both in one email.
Link format (token mode). The package builds the URL and both query
parameters; you supply only baseUrl:
https://app.example.com/verify-email?token=<token>&email=<email>
https://app.example.com/reset-password?token=<token>&email=<email>Your frontend reads both parameters and passes them to verifyEmail /
resetPassword — validation is per user keyed on email, so a link missing
either parameter cannot be redeemed.
baseUrl is required in token mode, enforced at boot with
InvalidAuthConfigException. Token-mode emails contain no readable credential,
so a missing base URL would produce mail that delivers successfully and cannot
be acted on. Failing at startup makes that a deploy-time error instead of a
support ticket.
In 'code' mode baseUrl stays optional. When set, it is used for the bare
page link; otherwise the package falls back to the FRONTEND_URL environment
variable (deprecated — logged once). With neither, code-mode emails carry no
link and nothing is logged: the code is in the body, so no link is needed. The
code itself is never placed in a query string in either mode.
One API, several brands: baseUrl per realm
With realms enabled, one deployment serves
several frontends and a link must open on the site the user actually came from.
baseUrl accepts a resolver as well as a string; it is called with the realm of
the request the credential is being issued for:
const SITES: Record<string, string> = {
'site-a': 'https://a.example.com',
'site-b': 'https://b.example.com',
};
verification: {
baseUrl: (realm?: string) => SITES[realm ?? ''] ?? 'https://www.example.com',
},It applies to both modes — the token link and the code page link — because both are built from the same resolved base. It is called once per link, so resolve from a map rather than a query, and it must be synchronous.
Boot validation is weaker for a resolver, by necessity. A string is checked
in full at startup. A resolver is only called once, with undefined, so the
realm-less case is checked and a per-realm return value is not — there is no
request at startup to supply a realm. What it returns for a specific realm is
checked when the link is built, and the two modes differ exactly as they do
elsewhere: token mode throws, because the link carries the only copy of the
credential; code mode logs a warning and sends the email without a link,
because the code is in the body.
Returning a value for realm === undefined is required. A request exempted by
realm.skip, or a single-realm deployment, has no realm.
Custom IEmailService implementations. Both send methods receive the
credential and its URL, and the mode determines which one is actionable:
sendVerificationEmail(email, code, expiresInMinutes, verificationUrl?)
sendPasswordResetEmail(email, resetToken, resetUrl, expiresInMinutes?)In token mode, render verificationUrl / resetUrl — never the raw token,
which the recipient cannot type. In code mode, render the code and omit the
link. expiresInMinutes reflects the mode's real expiry, so render it rather
than hardcoding a duration. Both trailing parameters are optional, so existing
implementations continue to compile.
Phone & SMS Verification
An authenticated user adds a phone number, receives a 6-digit code by SMS, and confirms it. All five methods take the current user, so they sit behind your authentication guard — this is account management, not a sign-in path.
| Method | Purpose |
|---|---|
| performSendPhoneVerification(input, user) | Store the number and send a code |
| performVerifyPhone(input, user) | Confirm the code, mark the number verified |
| performResendPhoneVerification(phoneNumber, user) | Send again, subject to the 60-second cooldown |
| performRemovePhoneNumber(user) | Clear the number and its verified state |
| performPhoneVerificationStatus(user) | Whether a number is present and verified |
Provide smsServiceInstance and verificationRepositoryInstance. ISmsService
has a single method, so the seam is small:
export class TwilioSmsService implements ISmsService {
async sendVerificationSms(phoneNumber: string, code: string): Promise<void> {
await this.client.messages.create({ to: phoneNumber, body: `Your code is ${code}` });
}
}Two things worth knowing:
- SMS is code-only.
features.verificationMode: 'token'governs email verification and password reset. A phone always receives a 6-digit code, because a link is not usable from a text message in the same way — soverification.baseUrlis irrelevant here. - Omitting
smsServiceInstancedoes not fail loudly. The package substitutes a no-op that logs, so codes are generated and stored but never delivered. Verification then only succeeds for someone reading your application logs.
The number's realm is scoped like any other identity field: with realms enabled,
findByPhoneNumber receives the realm, so the same number can exist once per
realm.
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'),
},
},
}),
})OAuthController mounts two token-exchange routes for native mobile sign-in: POST /auth/google/token with body { "idToken": "..." }, and POST /auth/facebook/token with body { "accessToken": "..." }. Both return { accessToken, refreshToken, user }.
Not using them? Leave them unmounted, and they answer 404 like any unknown path:
AuthModule.forRootAsync({
oauthController: false, // beside useFactory, not inside it
useFactory: () => ({ /* ... */ }),
})It sits beside useFactory because Nest fixes a module's controllers before async options resolve. GraphQL account linking does not use these routes and keeps working.
Errors. The routes answer with an HTTP status and a body carrying message, code and statusCode at the root, plus the fields listed:
| Status | code | When | Other fields |
|--------|--------|------|--------------|
| 404 | OAUTH_PROVIDER_NOT_CONFIGURED | The provider has no credentials in this deployment | provider |
| 401 | INVALID_OAUTH_TOKEN | The provider rejected the token | provider |
| 400 | OAUTH_MISSING_DATA | The provider returned no email, or an unverified one | provider, missingField; for a new Facebook user, fallbackToken and providerId |
| 409 | EMAIL_EXISTS_WITH_PASSWORD | The email belongs to a password account | email, linkingToken |
| 409 | OAUTH_ACCOUNT_ALREADY_LINKED | The account has a different ID linked for this provider | provider |
| 403 | ACCOUNT_LOCKED | Brute-force lockout | remainingLockoutTime (seconds) |
| 403 | ACCOUNT_INACTIVE | The account is suspended or deleted | -- |
| 429 | RATE_LIMIT_EXCEEDED | The client IP exceeded bruteForce.ipRateLimit | retryAfterSeconds |
Every RATE_LIMIT_EXCEEDED the package raises, here and over GraphQL (login,
passkey, sign-up, step-up), carries retryAfterSeconds, the time left in the
window, for a countdown.
With realms enabled, a request without a realm gets 400 Realm required first. Before 0.10.0 the lockout, inactive-account, rate-limit and unconfigured-provider cases answered 500.
Biometric Authentication
A device holds an ECDSA P-256 keypair, gated behind the OS biometric prompt. The server stores the public key and verifies a signature over a challenge it issued.
biometricRepositoryInstance: myBiometricRepo, // enables the capability
biometric: {
challengeExpirySeconds: 60, // default
challengeRateLimit: { maxAttempts: 5, windowMs: 60_000 }, // needs rateLimiterInstance
},⚠ What a successful authentication actually proves
It proves the client still holds the private key. It does not prove a biometric check happened. Nothing in an ECDSA signature attests to that, and the server cannot distinguish a key held in a hardware enclave from one generated in software. Whether a fingerprint was scanned — or a PIN accepted instead, which most mobile biometric APIs allow by default — is the client's word.
Treat this as possession of a device-bound key that the client promises to
gate. It is a good second factor and a good convenience login. Before making it
the sole factor for something sensitive, be clear that you are trusting the app,
not the biometric. WebAuthn's userVerification flag is what actually attests to
user verification, and is not this: for that, use passkeys.
The flow
// 1. Enrol, authenticated. The package generates the credential id — keep it
// on the device alongside the private key.
const { credentialId } = await biometricAuth.enrolCredential({
userId: user.id,
publicKey, // PEM, SPKI
deviceName: 'iPhone 15',
metadata: { yourDeviceString: '...' }, // optional, opaque to the package
realm, // required with realms enabled; the credential is bound to it
});
// 2. Request a challenge. No user id — the caller is usually signed out.
const challenge = await biometricAuth.requestChallenge(credentialId, {
ipAddress: req.ip,
});
if (!challenge) { /* rate limited */ }
// 3. The client signs `challenge.challenge` (base64) with the private key.
// 4. Verify and receive a session.
const session = await biometricAuth.authenticateWithBiometric(
{ challengeId: challenge.challengeId, credentialId, signature },
{ res, realm, ipAddress: req.ip },
);Also listCredentials(userId) for a device list and removeCredential(userId,
credentialId) to deactivate one.
What the package guarantees
| | |
|---|---|
| Challenge | 32 random bytes, single use, 60s default |
| Replay | A challenge is consumed atomically before anything is checked, so a retry loses the race — and a failed attempt still burns it |
| Cross-credential | A challenge issued for one credential cannot be answered with another's key |
| Realm | A credential signs in only to the realm it was enrolled in (since 0.15.0). With realms enabled, enrolCredential and authenticateWithBiometric require realm |
| Algorithm | Pinned per credential. A credential enrolled as ES256 is only verified as ES256; no verifier for its algorithm means refusal, not a fallback |
| Account status | The session comes from the same sink as every other login, so a suspended or soft-deleted user is refused after a valid signature |
| Enumeration | Every biometric failure returns the same 401, and a challenge is issued even for a credential that does not exist |
| Rate limiting | Per credential, and per IP so varying the credential id cannot evade it |
Implementing IBiometricRepository
One method has a contract you cannot satisfy with a read followed by a write:
async consumeChallenge(challengeId: string) {
// Atomic: mark used and return it, only if it was unused and unexpired.
const { count } = await prisma.biometricChallenge.updateMany({
where: { id: challengeId, used: false, expiresAt: { gt: new Date() } },
data: { used: true, usedAt: new Date() },
});
if (count !== 1) return null; // someone else claimed it, or it expired
return this.load(challengeId);
}Read-then-write lets two concurrent requests with one challenge both succeed, which is the replay single use exists to prevent.
Two more things your schema needs to know:
- Persist
realmon the credential and return it fromgetCredential. A credential without its realm, while realms are enabled, never authenticates. credentialIdon a challenge is not a foreign key. A challenge is stored for credential ids that do not exist, so that the signed-out request cannot be used to discover which credentials are enrolled.- No uniqueness on
deviceNameor any device string. It is a display label, never matched on. Key rotation is enrol-new then deactivate-old.
getCredential must return deactivated credentials rather than hiding them — the
package decides, so that a deactivated credential and an unknown one produce the
same answer.
There is no no-op fallback. Omit biometricRepositoryInstance and the
biometric services are not registered at all, rather than silently accepting
enrolments that can never authenticate.
Passkeys
WebAuthn passkeys (since 0.16.0): registration for a signed-in user, then
sign-in with no username at all. Enabled by passkeyRepositoryInstance plus
passkey.rp, and needs the optional peer @simplewebauthn/server (^14,
Node.js 20+).
passkeyRepositoryInstance: myPasskeyRepo, // enables the capability
passkey: {
rp: {
id: 'example.com', // the domain passkeys are bound to
name: 'Example',
origins: [
'https://app.example.com',
'android:apk-key-hash:<base64url SHA-256 of your signing certificate>',
],
},
// userVerification: 'required', // default; see below
},What a passkey sign-in proves
With userVerification: 'required' (the default): the user unlocked an
authenticator bound to this domain that holds the registered key. Unlike
the biometric device key, the authenticator itself
attests that it verified the user, and a lookalike domain cannot use the
credential. It does not prove which person unlocked the device, and a synced
passkey is only as safe as the iCloud or Google account it syncs through.
'required' adds no prompt for users. On phones and laptops the Face ID,
fingerprint or device PIN prompt is the passkey prompt, once per sign-in;
sessions then continue through refresh tokens as usual. What 'required'
changes is that the server refuses an authenticator reporting that it did not
verify the user, which in practice means a presence-only hardware key.
'preferred' and 'discouraged' accept those, and give up that guarantee.
The flow
Add a passkey (signed in)
performReauthenticate({ password }) -> { stepUpToken } (see Step-Up)
performPasskeyRegistrationOptions({ stepUpToken }) -> { challengeId, optionsJson }
client: navigator.credentials.create(...)
performVerifyPasskeyRegistration({ challengeId, responseJson, name? })
Sign in (signed out)
performPasskeyAuthenticationOptions() -> { challengeId, optionsJson }
client: navigator.credentials.get(...)
performVerifyPasskeyAuthentication({ challengeId, responseJson }) -> AuthResponseWebAuthn JSON crosses GraphQL as a String: send optionsJson to the platform
API unchanged, and send back JSON.stringify of what it returns. In a browser:
const { challengeId, optionsJson } = await passkeyAuthenticationOptions();
const credential = await navigator.credentials.get({
publicKey: PublicKeyCredential.parseRequestOptionsFromJSON(JSON.parse(optionsJson)),
});
await passkeyLogin({ challengeId, responseJson: JSON.stringify(credential.toJSON()) });(@simplewebauthn/browser's startRegistration({ optionsJSON }) /
startAuthentication({ optionsJSON }) do the same on browsers without the
parse…FromJSON helpers.) The sign-in options also serve browser autofill:
call navigator.credentials.get with mediation: 'conditional', and fetch
fresh options when they expire (120 s by default). On Flutter, use a plugin that
exchanges WebAuthn JSON with Android Credential Manager and iOS
ASAuthorization, and pass the same two strings through.
A consumer resolver, in the same shape as every other operation:
@ObjectType() class PasskeyOptions { @Field() challengeId!: string; @Field() optionsJson!: string; }
@ObjectType() class Passkey {
@Field() credentialId!: string; @Field() name!: string;
@Field() deviceType!: string; @Field() backedUp!: boolean;
@Field() createdAt!: Date; @Field({ nullable: true }) lastUsedAt?: Date;
}
@ObjectType() class StepUpToken { @Field() stepUpToken!: string; @Field() expiresAt!: Date; }
@InputType() class ReauthenticateInput {
@Field({ nullable: true }) password?: string;
@Field({ nullable: true }) emailCode?: string;
@Field({ nullable: true }) passkeyChallengeId?: string;
@Field({ nullable: true }) @MaxLength(16384) passkeyResponseJson?: string;
}
@InputType() class PasskeyRegistrationOptionsInput { @Field() stepUpToken!: string; }
@InputType() class VerifyPasskeyInput {
@Field() challengeId!: string;
@Field() @MaxLength(16384) responseJson!: string;
@Field({ nullable: true }) @MaxLength(64) name?: string;
}
@Resolver()
export class AppAuthResolver extends BaseAuthResolver<User> {
@Mutation(() => StepUpToken) @UseGuards(JwtAuthGuard)
reauthenticate(@Args('input') input: ReauthenticateInput, @CurrentUser() user: User, @Context() ctx: any) {
return this.performReauthenticate(user, input, ctx);
}
@Mutation(() => PasskeyOptions) @UseGuards(JwtAuthGuard)
passkeyRegistrationOptions(@Args('input') input: PasskeyRegistrationOptionsInput, @CurrentUser() user: User, @Context() ctx: any) {
return this.performPasskeyRegistrationOptions(user, input, ctx);
}
@Mutation(() => Passkey) @UseGuards(JwtAuthGuard)
verifyPasskeyRegistration(@Args('input') input: VerifyPasskeyInput, @CurrentUser() user: User, @Context() ctx: any) {
return this.performVerifyPasskeyRegistration(user, input, ctx);
}
@Mutation(() => PasskeyOptions)
passkeyAuthenticationOptions(@Context() ctx: any) {
return this.performPasskeyAuthenticationOptions(ctx);
}
@Mutation(() => AuthResponse)
passkeyLogin(@Args('input') input: VerifyPasskeyInput, @Context() ctx: any) {
return this.performVerifyPasskeyAuthentication(input, ctx) as Promise<AuthResponse>;
}
}PasskeyService (exported) has the same operations if you need them outside a
resolver.
What the package guarantees
| | |
|---|---|
| Phishing | Origin and rpId are verified against passkey.rp; an assertion made for another site fails |
| Replay | Every challenge is single use, consumed atomically before anything is checked, and burned by a failed attempt. It carries its purpose, user and realm, so a sign-in challenge cannot be spent on registration or step-up |
| Adding a passkey | Needs a step-up token. A passkey survives a password change and logout-all, so a stolen session must not be able to add one |
| Realms | A passkey signs in only to the realm it was registered in, checked on the stored record whatever the repository does |
| User handle | Must match the stored one on sign-in (the library does not check it) |
| Cloned authenticators | A counting authenticator's counter must increase, enforced by the package with a compare-and-set; a regression is refused and logged as PASSKEY_COUNTER_REGRESSION. Synced passkeys always report 0, which is accepted |
| Account status | Sessions come from issueAuthSession, so a suspended user is refused after a valid assertion, with ACCOUNT_INACTIVE rather than the generic failure |
| Enumeration | Sign-in options take no input; every sign-in failure is the same PASSKEY_AUTHENTICATION_FAILED (401) |
| Duplicates | A credential id already registered to anyone is refused with PASSKEY_ALREADY_REGISTERED (409), leaving the existing one untouched |
| Last way in | Removing the only passkey of an account with no password and no social identity is refused with CANNOT_REMOVE_LAST_AUTH_METHOD (409) |
| Owner notified | The package emails the owner when a passkey is added or removed (see Notification emails) |
| Rate limiting | Options are limited per user (registration, step-up) and per IP (sign-in); each verification consumes a challenge, so it is bounded by them |
Platform setup
None of this is visible to a compiler or a test suite; each item fails as an opaque error on the device.
| Platform | Needs |
|---|---|
| Web | The frontend on HTTPS, on a host equal to rp.id or under it, listed exactly in rp.origins (scheme, host, port; no path, no trailing slash) |
| Android | https://<rp.id>/.well-known/assetlinks.json granting delegate_permission/common.get_login_creds to your package name and SHA-256 certificate fingerprint, and android:apk-key-hash:<hash> in rp.origins |
| iOS | https://<rp.id>/.well-known/apple-app-site-association listing your app under webcredentials.apps, and webcredentials:<rp.id> in the app's Associated Domains entitlement. iOS reports the web origin https://<rp.id>, which must be in rp.origins |
The Android origin is the base64url SHA-256 of the signing certificate,
not the colon-separated hex keytool prints. Convert it:
echo 'AB:CD:...:EF' | tr -d ':' | xxd -r -p | base64 | tr '+/' '-_' | tr -d '='With Play App Signing, use the fingerprint of Google's app signing key (Play Console → App integrity), not your upload key. Boot refuses an Android origin that is not 43 base64url characters.
Several brands, one API: every rp field can be a resolver taking the
request's realm, e.g. id: (realm) => brands[realm].domain. Resolved values are
checked per call, since boot cannot know every realm.
Implementing IPasskeyRepository
Two methods must be one conditional statement each, never a read followed by a write:
async consumeChallenge(challengeId: string) {
const { count } = await prisma.passkeyChallenge.updateMany({
where: { id: challengeId, used: false, expiresAt: { gt: new Date() } },
data: { used: true, usedAt: new Date() },
});
if (count !== 1) return null;
return this.loadChallenge(challengeId);
}
async updateCounter({ credentialId, expectedCounter, newCounter, backedUp, lastUsedAt }) {
const { count } = await prisma.passkeyCredential.updateMany({
where: { credentialId, counter: expectedCounter },
data: { counter: newCounter, backedUp, lastUsedAt },
});
return count === 1;
}And createCredential must throw PasskeyCredentialExistsError on a duplicate
credentialId for any user, from a unique constraint (map Prisma's
P2002). A reference schema:
model PasskeyCredential {
credentialId String @id // base64url, unique across all users
userId String
realm String? // required when realms are enabled
publicKey String // base64url COSE key
counter BigInt @default(0) // uint32 on the wire; Int is too small
userHandle String
transports String[]
deviceType String
backedUp Boolean
aaguid String?
name String
createdAt DateTime @default(now())
lastUsedAt DateTime?
isActive Boolean @default(true)
metadata Json?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId, realm])
}
model PasskeyChallenge {
id String @id @default(uuid())
challenge String
purpose String // registration | authentication | step-up
userId String? // not a foreign key
realm String?
userHandle String? // registration only
expiresAt DateTime
used Boolean @default(false)
usedAt DateTime?
@@index([expiresAt])
}getCredential returns deactivated credentials too; the package decides.
Check your adapter against the contract with the helper the package's own tests use, against a real database:
import { assertPasskeyRepositoryContract } from '@ambushsoftworks/nestjs-auth-graphql/testing';
it('meets the passkey repository contract', async () => {
await assertPasskeyRepositoryContract(() => new PrismaPasskeyRepository(prisma), {
userIds: [alice.id, bob.id], // existing users: the reference schema has a foreign key to User
realms: false, // only if you do not use realms
cleanup: async ({ credentialIds, challengeIds }) => {
await prisma.passkeyCredential.deleteMany({ where: { credentialId: { in: credentialIds } } });
await prisma.passkeyChallenge.deleteMany({ where: { id: { in: challengeIds } } });
},
});
});It resolves when the adapter is correct and otherwise rejects naming the rule broken: concurrent claims of one challenge, a counter column too small for a uint32, an unmapped duplicate, a dropped realm, hidden deactivated credentials, expiry left to a TTL. Framework-agnostic.
| Option | Default | Use it when |
|---|---|---|
| userIds | invented ids | Your credentials table has a foreign key to users, as the reference schema does. At least two existing users; they may already have passkeys. Requires cleanup |
| realms | true | false when you do not set realmExtractorInstance and store no realm |
| cleanup | none | Always, against a shared database. Called once, pass or fail, with every id created; the list may include ids never stored, so delete with deleteMany |
examples/passkeys/ has the reference schema and a Prisma adapter that pass
this contract on PostgreSQL 16, with and without realms.
InMemoryPasskeyRepository is exported for tests and single-instance
development.
Driving real ceremonies in your own tests: SoftwareAuthenticator, from
the same /testing entry, produces real WebAuthn responses (CBOR, COSE keys,
signatures) from the options your API returns, with options to forge any field:
import { SoftwareAuthenticator } from '@ambushsoftworks/nestjs-auth-graphql/testing';
const device = new SoftwareAuthenticator();
const response = device.create(JSON.parse(optionsJson), { origin: 'https://app.example.com' });
// ... later, for sign-in:
const assertion = device.get(JSON.parse(signInOptionsJson), { origin: 'https://app.example.com' });Notification emails
The package emails the account owner when a passkey is added and when one
is removed, through IEmailService.sendPasskeyAddedEmail /
sendPasskeyRemovedEmail. The "added" email matters most: a passkey survives a
password change and signing out everywhere, so it is how an owner learns of one
they did not add.
| Your email setup | What happens |
|---|---|
| email: {...} (default renderer) | Sent, with the package's templates |
| ConfigurableEmailService with your own renderer | Sent when your renderer implements renderPasskeyAddedEmail / renderPasskeyRemovedEmail; otherwise nothing is sent |
| Your own IEmailService | Sent when it implements sendPasskeyAddedEmail(email, passkeyName, addedAt) / sendPasskeyRemovedEmail(email, passkeyName, removedAt); otherwise nothing is sent |
passkeyName is chosen by the user: escape it in any HTML you render. The
default templates do.
To send your own instead, or none, turn either off:
passkey: {
rp: { /* ... */ },
emailNotifications: { added: true, removed: false }, // both default to true
},Don't also send them from onPasskeyRegistered / onPasskeyRemoved, or
owners get two. The hooks still fire, for anything else you want to do.
Passwordless sign-up
A new account from an email address and a passkey, with no password (since 0.17.0). The account is created only once its passkey exists, so an abandoned sign-up leaves nothing behind.
performStartPasskeySignup({ email }) -> { signupId, expiresAt } (emails a 6-digit code)
performVerifyPasskeySignupCode({ signupId, code }) -> { challengeId, optionsJson, signupId } (verified)
client: navigator.credentials.create(...)
performCompletePasskeySignup({ signupId, challengeId, responseJson, name? }) -> AuthResponse
after a cancelled or timed-out device sheet (PASSKEY_SIGNUP_CHALLENGE_EXPIRED):
performPasskeySignupOptions({ signupId }) -> { challengeId, optionsJson, signupId } (no code)Keep the signupId step 2 returns: it is marked as having passed its code,
with the same expiry. Completion and fresh options accept only that one, and
neither spends a code attempt, so a cancelled sheet costs the user nothing.
The result is an account with no password, emailVerified: true, and one
passkey, signed in exactly as performLogin signs in (cookies included).
Available when passkeys are configured and the email service has
sendSignupCodeEmail (ConfigurableEmailService does, with the default
renderer); otherwise PASSKEY_SIGNUP_UNAVAILABLE.
| | |
|---|---|
| An email that already has an account | Same answer as a new one, and nothing is sent (as password sign-up under preventEnumerationOnSignup). No code can complete it. Show "Already have an account? Sign in or reset your password" beside the code screen |
| The code | 6 digits, 15 minutes, always a code (never a link, whatever verificationMode says). Three attempts per sign-up, right or wrong; then PASSKEY_SIGNUP_CODE_EXHAUSTED and the user starts again |
| Rate limits | 5 starts an hour per IP; per email, one a minute and five an hour. Needs a shared rateLimiterInstance across instances |
| Nothing stored before step 3 | signupId is a signed token holding the email, realm and a keyed hash of the code; the attempt count lives in the rate limiter. No new table |
| Failure at step 3 | The passkey is verified before anything is written. If the passkey cannot be saved after the user was created, the user is deleted. If that delete fails too, it is logged as an error; that account has no way in but password reset |
| Notifications | onSignup and onPasskeyRegistered fire. No "passkey added" email for this first passkey: the user created it seconds ago, and onSignup is where a welcome email belongs |
| Recovery | A user who loses their passkey resets a password by code (Password Reset), which a password-less account without a social identity may do |
| Step-up | With stepUp.issueOnLogin, the response carries a step-up token, like any fresh sign-in |
| Code | Status | When |
|---|---|---|
| PASSKEY_SIGNUP_CODE_INVALID | 400 | Wrong code (or an email that already had an account) |
| PASSKEY_SIGNUP_CODE_EXHAUSTED | 429 | Third attempt used |
| PASSKEY_SIGNUP_EXPIRED | 400 | signupId expired, malformed, or from another realm |
| PASSKEY_SIGNUP_EMAIL_TAKEN | 409 | The email gained an account during the sign-up (only reachable with the code) |
| PASSKEY_SIGNUP_CODE_REQUIRED | 400 | Options or completion with a signupId whose code was not checked: use the one step 2
