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

nestjs-slightly-better-auth

v2.0.0

Published

A Better Auth integration for NestJS with Express and Fastify platforms, Socket.IO and ws gateways, microservice RPC transports, and admin, organization and API-key authorization

Readme

nestjs-slightly-better-auth

npm version Monthly downloads CI

A NestJS integration for Better Auth. A construction-time Better Auth plugin bridges hooks and cookies into Nest; the Nest module mounts Better Auth's routes on Express or Fastify, authenticates controllers, GraphQL operations, WebSocket messages and microservice messages through one guard, and evaluates admin, organization and API-key permissions through Better Auth's own endpoints.

Install

pnpm add nestjs-slightly-better-auth better-auth

Peer dependencies: @nestjs/common and @nestjs/core 12, better-auth 1.7.5 or newer (below 2), reflect-metadata and rxjs. The package requires Node.js 24.11 or newer (engines.node is >=24.11.0). Install the Nest platform adapter your application uses, @nestjs/platform-express or @nestjs/platform-fastify; the ./express and ./fastify entries import neither at runtime.

The ./testing and ./testing/conformance entries also need the optional peer @nestjs/testing 12; the root entry never loads it or a test runner. API keys use Better Auth's @better-auth/api-key plugin, which the application installs and registers itself (pnpm add @better-auth/api-key); the ./api-key entry never imports it. Compatibility lists the tested versions and module systems.

The ./graphql entry has two optional peer dependencies, @nestjs/graphql ^14.0.2 and graphql ^16.14.2, plus the Nest driver of your GraphQL server:

# Apollo on Express
pnpm add @nestjs/graphql graphql @nestjs/apollo @apollo/server @as-integrations/express5
# Mercurius on Fastify
pnpm add @nestjs/graphql graphql @nestjs/mercurius mercurius @nestjs/platform-fastify

The ./websockets entry also needs the optional peer @nestjs/websockets 12 and the Nest adapter for your socket library:

pnpm add @nestjs/websockets @nestjs/platform-socket.io # Socket.IO
pnpm add @nestjs/websockets @nestjs/platform-ws # raw ws

The ./microservices entry also needs the optional peer @nestjs/microservices 12 and the client library Nest uses for your transport, for example:

pnpm add @nestjs/microservices @nats-io/transport-node

Nest's gRPC transport uses @grpc/grpc-js and @grpc/proto-loader, Kafka uses kafkajs, RabbitMQ uses amqplib and amqp-connection-manager, MQTT uses mqtt, and Redis uses ioredis. TCP needs no client library. Applications that do not import ./microservices do not need any of these packages.

Compatibility

| Dependency | Supported | Tested | | -------------------------------- | ------------------------------------------------- | -------------------------------------------------------------------------------- | | Node.js | 24.11.0 or newer | 24.11.0 and the current LTS | | @nestjs/common, @nestjs/core | ^12.0.3 | 12.0.3 and the newest 12.x release, with the matching @nestjs/platform-express | | better-auth | >=1.7.5 <2 | 1.7.5 and the newest 1.x release | | TypeScript declarations | nodenext, node16 ESM and bundler resolution | TypeScript 5.9, the newest 6.x and 7 in each resolution mode | | Bundlers | esbuild, nest build --webpack | esbuild 0.28 and Nest CLI 12 with webpack 5 |

The packaging tests pack this package, install the tarball with pnpm into temporary applications outside the workspace and run them with the peers installed at their declared floors and at the newest versions inside the peer ranges. The newest versions are those that pnpm's default minimum release age admits, so a new release enters the matrix after that delay. Each application compiles one Nest source as ESM and as CommonJS, signs up users of a default and a named Better Auth instance, calls a guarded route of each with its session cookie, checks that neither instance accepts the other's cookie, and closes. CI runs these tests on the current Node.js LTS and on Node.js 24.11.0.

The declared floors are the supported floors. NestJS 11 and Better Auth releases before 1.7.5 are outside the peer ranges and are not tested.

Module systems:

  • Node.js resolves import and require() to the same ESM build through the module-sync export condition, so ESM and CommonJS applications share one copy of every class. The .cjs build is the fallback for resolvers without module-sync; the tests also run the applications with require() routed to it.
  • Better Auth publishes ESM only, so CommonJS applications need require() of ES modules, which Node.js 24 enables by default. With --no-experimental-require-module, require() resolves every entry to its .cjs file, but only ./platform and ./fastify load; every other entry stops at its first Better Auth import with ERR_REQUIRE_ESM.
  • With TypeScript module: nodenext, ESM and CommonJS files compile against the published declarations; module: preserve with moduleResolution: bundler uses the ESM declarations. With module: node16, ESM files compile, but CommonJS files cannot import NestJS 12 or Better Auth, whose declarations are ESM only; use nodenext for CommonJS applications.
  • Each optional entry loads with only its own optional peers installed, and every other entry loads without any optional peer.

Toolchains:

  • TypeScript 5.9, the newest 6.x and 7 compile the application, a node16 CommonJS file that imports only this package, and a principal-kind check in each resolution mode. That check runs once with and once without an import of ./api-key: 'api-key' is a PrincipalKind only in programs that import it.
  • esbuild bundles the compiled application, including this package and Better Auth, as ESM and as CommonJS, and each bundle authenticates both instances from a directory without node_modules. The ESM bundle needs a createRequire banner for bundled CommonJS dependencies and --splitting: without it, esbuild 0.28 leaves Better Auth's memoryAdapter, which Better Auth also imports dynamically, uninitialized. Mark Nest's optional @nestjs/microservices, @nestjs/websockets, class-transformer and class-validator external when the application does not install them.
  • nest build --webpack compiles the application with the Nest CLI's default webpack configuration, which keeps node_modules external, and the build authenticates both instances.

| Setup | Status | | ------------------------------------------------------------------------------------------------------- | --------------------------------------------------- | | ESM and CommonJS applications on Node.js 24.11.0 or newer | Supported and tested | | TypeScript 5.9, 6.x and 7 with nodenext, node16 ESM or bundler resolution | Supported and tested | | esbuild ESM bundles with --splitting, esbuild CommonJS bundles, nest build --webpack | Supported and tested | | TypeScript node16 CommonJS files that import NestJS 12 or Better Auth | Unsupported: their declarations are ESM only | | CommonJS with --no-experimental-require-module | Unsupported: only ./platform and ./fastify load | | NestJS 11, Better Auth before 1.7.5 | Unsupported: outside the peer ranges | | TypeScript before 5.9, the Nest CLI's rspack builder, webpack bundles of node_modules, other bundlers | Untested |

The packaging tests also inspect the packed archive. It contains only package.json, this README, the license and the dist files that the export map reaches; source maps name only this package's sources, and runtime maps embed them. Each entry exports exactly the names listed in fixtures/public-api.json, and no emitted file has a default export or top-level await.

Entry points

Every entry ships ESM and CommonJS builds with declarations. The module-sync export condition gives Node import and require() consumers one shared ESM module identity, and optional peers load only through the entry that needs them. The changelog records the version that introduces each entry.

| Entry | Provides | Extra peers | | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------- | | . | BetterAuthModule, BetterAuthService, BetterAuthGuard, BetterAuthScopeInterceptor, access, principal and hook decorators, requirement composition, token helpers, errors, the session unit, httpTransport() and betterAuthCorsOrigin() | none | | ./plugin | nestjs(), the Better Auth construction plugin | none | | ./platform | Node/Web helpers for third-party platforms and transports: toWebHeaders(), readBoundedBody(), writeWebResponse(), appendSetCookie(), upgradeRequestUrl() and related functions | none | | ./express | expressPlatform(), ExpressPlatform | none | | ./fastify | fastifyPlatform(), FastifyPlatform | none | | ./graphql | apolloTransport(), mercuriusTransport(), mercuriusSubscriptionContext() | @nestjs/graphql, graphql | | ./websockets | socketIoTransport(), wsTransport(), withUpgradeRequest(), WsConnectionAuth, WS_CONNECTION_AUTH, wsCloseCodeFor() | @nestjs/websockets | | ./microservices | rpcTransport() and the credential carriers grpcCarrier(), natsCarrier(), kafkaCarrier(), rmqCarrier(), mqttCarrier(), payloadCarrier() | @nestjs/microservices | | ./admin | permission(), RequirePermission() | none | | ./organization | orgPermission(), RequireOrgPermission(), orgMember(), RequireOrgMember(), activeOrganization(), fromParam(), fromHeader(), organizationRef(), ActiveOrganizationId(), ActiveMemberRole() | none | | ./api-key | apiKeyPrincipal(), apiKeyPermission(), RequireApiKeyPermission(), API_KEY_PRINCIPAL_KIND | none | | ./testing | overrideAuthGuard(), overridePrincipal(), overrideDecisions(), stampPrincipal(), testPrincipal(), authHeadersFor(), initTestApp() | @nestjs/testing | | ./testing/conformance | Runner-agnostic conformance kits for platforms, transports, principal sources and policies | @nestjs/testing |

The design workspace holds the reviewed specification, its review ledger and the implementation plan.

Construct the Better Auth instance

Add nestjs() from ./plugin to every betterAuth() call, as the last entry of plugins:

// auth.ts
import { apiKey } from "@better-auth/api-key";
import { betterAuth } from "better-auth";
import { admin, bearer, organization } from "better-auth/plugins";
import { nestjs } from "nestjs-slightly-better-auth/plugin";
import { Pool } from "pg";

export const auth = betterAuth({
  baseURL: process.env.BETTER_AUTH_URL,
  database: new Pool({ connectionString: process.env.DATABASE_URL }),
  emailAndPassword: { enabled: true },
  // nestjs() comes last, after every plugin with hooks.
  plugins: [admin(), organization(), apiKey(), bearer(), nestjs()],
});

declare module "nestjs-slightly-better-auth" {
  interface Register {
    auth: typeof auth;
  }
}

The example stores data in PostgreSQL through pg (pnpm add pg); any Better Auth database works. The plugin is pure construction-time configuration: it adds fixed hook entries and database-hook dispatchers when Better Auth builds its pipeline, and the Nest module binds its hook tables to them later. The module never wraps, clones or mutates the auth object. Startup fails with PLUGIN_MISSING when an instance has no nestjs(), and with PLUGIN_SHARED_BETWEEN_INSTANCES when two instances share one plugin object, so call nestjs() inside each betterAuth() call. When a plugin after nestjs() declares after-hooks, startup logs W_PLUGIN_NOT_LAST: those hooks would run after the plugin's cookie bridge and endpoint-result observer, which then miss their changes. Nest @BeforeAuth() and @AfterAuth() hooks run after the application's own hooks option and after the hooks of earlier plugins.

nestjs({ clientIpHeader }) controls how Better Auth learns the client IP. By default the plugin adds a random header name per nestjs() call (x-nsba-ip-<32 hex characters>) to advanced.ipAddress.ipAddressHeaders, the platform sets it from its own trust-proxy-aware client IP, and the kernel deletes any client-sent copy, so a client cannot choose its rate-limit bucket or the IP recorded on its sessions. A string pins a fixed header name for a trusted sidecar that calls auth.handler itself; false contributes nothing, and Better Auth's own advanced.ipAddress settings apply.

The Register augmentation types BetterAuthService, AuthSession, AuthUser, AuthPrincipal, hook contexts and permission maps from typeof auth. Named instances go under instances: { admin: typeof adminAuth }. Without the augmentation, types fall back to Better Auth's default session shape and permission maps accept any resource. With it, the default instance's forRoot() rejects an auth of another type.

Register the module

Register the default instance once, in the root module:

// app.module.ts
import { Module } from "@nestjs/common";
import { BetterAuthModule } from "nestjs-slightly-better-auth";
import { apiKeyPrincipal } from "nestjs-slightly-better-auth/api-key";
import { expressPlatform } from "nestjs-slightly-better-auth/express";
import { auth } from "./auth";

@Module({
  imports: [
    BetterAuthModule.forRoot({
      auth,
      platforms: [expressPlatform()],
      principals: [apiKeyPrincipal()],
    }),
  ],
})
export class AppModule {}

forRoot() returns a global module by default. It registers BetterAuthGuard as APP_GUARD and BetterAuthScopeInterceptor as APP_INTERCEPTOR, so every controller route requires a session unless it opts out. Undecorated handlers follow defaultAccess ("authenticated" by default; "public" makes the application opt-in).

Static and runtime options

Options that decide which providers exist are static. Everything else is a runtime option, which forRootAsync() can compute from injected providers.

| Kind | Options | | --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | App-level static, default instance only | platforms, transports | | Instance static | name, global (default true), globalGuard (default true for the default instance, false for named ones), globalScope (default true, default instance only), principals | | Runtime | auth, defaultAccess, defaultRequirements, session, http (mount, bodyLimit, around, allowRootMount, allowRequestDerivedBaseURL, diagnostics), cookies.forwardDirectCalls, originCheck (mode, missingOrigin), errors (map, exposeRawCause), limits.maxAuthorizationCallsPerRequest, logSummary |

forRootAsync() takes the static options next to useFactory; the factory returns runtime options only. Returning a static key from the factory is a compile-time error with a readable message, and a runtime error (STATIC_OPTION_IN_FACTORY) for untyped callers:

import { Injectable, Module } from "@nestjs/common";
import { BetterAuthModule } from "nestjs-slightly-better-auth";
import { fastifyPlatform } from "nestjs-slightly-better-auth/fastify";
import { auth } from "./auth";

@Injectable()
export class AuthSettings {
  readonly bodyLimit = Number(process.env.AUTH_BODY_LIMIT ?? 1_048_576);
}

@Module({
  providers: [AuthSettings],
  exports: [AuthSettings],
})
export class AuthSettingsModule {}

@Module({
  imports: [
    BetterAuthModule.forRootAsync({
      platforms: [fastifyPlatform()],
      imports: [AuthSettingsModule],
      inject: [AuthSettings],
      useFactory: (settings: AuthSettings) => ({
        auth,
        http: { bodyLimit: settings.bodyLimit },
      }),
    }),
  ],
})
export class AppModule {}

The factory can also build the instance from injected providers, for example a database pool. Register its type through the factory's return type, interface Register { auth: ReturnType<typeof createAuth> }, and give the Better Auth CLI its own module-level instance.

Default and named instances

An application has one default instance and any number of named instances. Each named instance has its own injection tokens, nestjs() binding, mount path, principal sources and runtime options; platforms and transports are app-wide and belong to the default instance only (APP_EXTENSIONS_ON_NAMED_INSTANCE otherwise). Named registrations require a default registration (NO_DEFAULT_INSTANCE). Two instances must not share a mount path (OVERLAPPING_MOUNTS) or cookie names on overlapping scopes (COOKIE_NAME_COLLISION), so give the second instance its own basePath and advanced.cookiePrefix:

import { Controller, Get, Inject, Module } from "@nestjs/common";
import { betterAuth } from "better-auth";
import {
  type AuthOf,
  type AuthUser,
  BetterAuthModule,
  BetterAuthService,
  CurrentUser,
  getBetterAuthServiceToken,
  UseAuthInstance,
} from "nestjs-slightly-better-auth";
import { expressPlatform } from "nestjs-slightly-better-auth/express";
import { nestjs } from "nestjs-slightly-better-auth/plugin";
import { auth } from "./auth";

export const adminAuth = betterAuth({
  baseURL: process.env.BETTER_AUTH_URL,
  basePath: "/api/admin-auth",
  advanced: { cookiePrefix: "admin-auth" },
  emailAndPassword: { enabled: true },
  plugins: [nestjs()],
});

declare module "nestjs-slightly-better-auth" {
  interface Register {
    instances: { admin: typeof adminAuth };
  }
}

@UseAuthInstance("admin")
@Controller("admin")
export class AdminController {
  constructor(
    @Inject(getBetterAuthServiceToken("admin"))
    private readonly adminAuth: BetterAuthService<AuthOf<"admin">>,
  ) {}

  @Get("me")
  me(@CurrentUser() operator: AuthUser<"admin">) {
    return { id: operator.id };
  }

  @Get("session")
  async session() {
    const session = await this.adminAuth.getSession();
    return { expiresAt: session?.session.expiresAt };
  }
}

@Module({
  imports: [
    BetterAuthModule.forRoot({ auth, platforms: [expressPlatform()] }),
    BetterAuthModule.forRoot({ name: "admin", auth: adminAuth }),
  ],
  controllers: [AdminController],
})
export class AppModule {}

The one global guard evaluates each handler against the instance named by @UseAuthInstance() (method over class, default "default"). Named instances do not register a second global guard; at most one registration may set globalGuard: true (DUPLICATE_GLOBAL_GUARD). A named instance registers the same way through forRootAsync({ name: "admin", useFactory }).

Injection tokens

Inject with Nest's @Inject() and the token helpers; the default instance also resolves by class (BetterAuthService). An empty alias or "default" returns the default token, and a named alias returns <BASE_TOKEN>_<alias>.

| Helper | Resolves to | | ------------------------------------ | -------------------------------------------------------------------------------------------------------------------- | | getBetterAuthServiceToken(alias?) | BetterAuthService for the instance | | getBetterAuthInstanceToken(alias?) | the exact object betterAuth() returned | | getBetterAuthOptionsToken(alias?) | the instance's resolved runtime options | | getBetterAuthHandleToken(alias?) | the instance's AuthHandle, which runs auth.api calls inside an explicit scope and checks origins, for extensions |

BetterAuthService exposes instance, api and context() (the awaited $context), mount() (base path, body limit and platform id, or undefined when unmounted), the readers described under Access, principals and readers, headersFrom(request) and the direct-call helpers described under Direct auth.api calls.

HTTP platforms

Pass exactly one platform that supports the application's HTTP adapter (NO_PLATFORM or AMBIGUOUS_PLATFORM otherwise). Microservice-only and application-context bootstraps have no HTTP adapter and mount nothing.

// main.ts
import { NestFactory } from "@nestjs/core";
import { ExpressAdapter } from "@nestjs/platform-express";
import { betterAuthCorsOrigin } from "nestjs-slightly-better-auth";
import { AppModule } from "./app.module";
import { auth } from "./auth";

const app = await NestFactory.create(AppModule, new ExpressAdapter());
// Optional: follow Better Auth's exact trusted origins for credentialed CORS.
app.enableCors({ origin: betterAuthCorsOrigin(auth), credentials: true });
app.enableShutdownHooks();
await app.listen(3000);

For Fastify, pass fastifyPlatform() from nestjs-slightly-better-auth/fastify and create the application with new FastifyAdapter(). Both factories accept { clientIp }, a function that returns the client IP from the native request; the default is Express req.ip or Fastify request.ip, which honor the adapter's proxy trust setting.

Mount path. Each instance's routes are mounted at the path Better Auth routes: the pathname of its resolved base URL, which is baseURL with basePath (default /api/auth) appended when baseURL has no path of its own, or basePath alone when the base URL is unset or dynamic. Auth routes live outside setGlobalPrefix() and enableVersioning(); put a prefix into basePath or baseURL instead. Everything below the base path goes to Better Auth, which answers unknown paths, methods and trailing slashes itself. A root mount (/) requires http.allowRootMount, and http.mount: false keeps an instance guard-only. http.diagnostics (on by default outside production) logs each auth path Better Auth answers 404 for once, with the closest endpoint path. A Nest controller route under an auth base path takes precedence and logs W_ROUTE_SHADOWS_AUTH.

Bodies. Auth routes receive exactly the bytes the client sent, bounded by http.bodyLimit (default 1 MiB; a number of bytes or a string such as "2mb"). A larger body receives 413 { code: "PAYLOAD_TOO_LARGE" } on both platforms, with the host's CORS headers, and an unparsable request URL receives 400 { code: "INVALID_REQUEST_URL" }. Application routes keep Nest's parsers, rawBody, useBodyParser() limits and Fastify's secure JSON parser. Express captures auth-route bodies before Nest registers its parsers; global middleware that reads the raw request stream without checking whether it already ended waits on a finished stream for auth routes. When an Express instance passed to new ExpressAdapter(app) already parsed the body, the platform recovers it from req.rawBody or re-serializes req.body and logs W_BODY_ALREADY_CONSUMED once, because raw-signature endpoints such as webhooks then receive different bytes. Fastify parses auth routes in its own encapsulated context and never reaches that path.

Responses and cookies. Responses are written verbatim, including redirects, streams and arbitrary content types. Every Set-Cookie line is appended to any cookies already set, except a line identical to the latest line already set for the same cookie, Vary values are merged, and host headers such as CORS are kept. Nest interceptors, pipes and serializers never run on auth routes; exception filters receive only failures that reach the host error pipeline, such as BetterAuthInfrastructureError or an APIError rethrown under Better Auth's onAPIError: { throw: true }. http.around wraps every auth-route exchange for request contexts or tracing:

BetterAuthModule.forRoot({
  auth,
  platforms: [expressPlatform()],
  http: {
    around: [
      async (call, next) => {
        const started = Date.now();
        const response = await next();
        console.log(call.instance, call.request.url, Date.now() - started);
        return response;
      },
    ],
  },
});

Base URL. In production (any NODE_ENV other than development, dev or test, including an unset one), startup fails with UNSAFE_BASE_URL when Better Auth has no static base URL and no dynamic baseURL.allowedHosts configuration: Better Auth would otherwise derive token links and a trusted origin from each request's Host header. http.allowRequestDerivedBaseURL: true accepts that behavior explicitly. A production instance that signs with Better Auth's public default secret fails with DEFAULT_SECRET.

Proxies and rate limits. Configure proxy trust on the Nest adapter: Express app.set("trust proxy", ...) or Fastify trustProxy. The boot summary prints each platform's trust mode; Express reports it from its settings, and Fastify reports unknown because it exposes no public inspection API, which disables the diagnostics that depend on it. W_PROXY_TRUST_ALL warns when the platform trusts every proxy in production. Better Auth enables its rate limiter only when it saw NODE_ENV=production at import time; W_RATE_LIMIT_DISABLED reports a production deployment where the limiter resolved off, for example because .env set NODE_ENV after auth.ts was imported. Set rateLimit.enabled explicitly in that case.

Express specifics. The platform recognizes a request by Node object identity: an http.IncomingMessage or stream request whose res is the http.ServerResponse bound to it. Requests dispatched with light-my-request's inject(app.getHttpAdapter().getInstance(), ...) are supported. inject() re-parents Express's shared request and response prototypes for the whole process, and a listening Express server in the same process then fails inside light-my-request, so keep injected and listening tests in separate test files. Express preserves native controller parsing for unconditional routes that overlap the auth mount. Body-capable controller routes that also depend on host or non-URI version conditions are rejected at startup with CONDITIONAL_ROUTE_SHADOW; move them outside the auth mount. The integration checks the effective Express router flags, because changing application settings after router creation does not change existing route matching.

HTTP/2. Fastify adapters created with http2: true are supported: HTTP/2 pseudo-headers never reach Better Auth, and host is taken from :authority when absent. Nest's Express adapter has no HTTP/2 server.

CORS. The library configures no CORS. Host CORS middleware and @fastify/cors apply to auth routes, including preflight and the 413 response. betterAuthCorsOrigin(auth, { allowPatterns }) returns an origin callback that accepts Better Auth's exact trusted http(s) origins, including plugin-contributed ones; wildcard and custom-scheme entries need allowPatterns: true, and a function-valued trustedOrigins throws at first use because a CORS callback has no request. Every origin the callback accepts gains credentialed read access to the routes it covers, so prefer to scope it to the auth mount and give application routes their own allow-list.

Access, principals and readers

The guard compiles one plan per controller class and handler at startup and validates every plan before the application starts: contradictory decorators, unknown instances, unsatisfiable principal kinds and uncovered transport handlers fail boot with one aggregated AUTH_BOOT_FAILED error that lists every problem.

import { Controller, Delete, Get } from "@nestjs/common";
import {
  AcceptPrincipals,
  type AuthPrincipal,
  type AuthSession,
  type AuthUser,
  CurrentPrincipal,
  CurrentSession,
  CurrentUser,
  OptionalAuth,
  Public,
  RequireAuth,
} from "nestjs-slightly-better-auth";
import "nestjs-slightly-better-auth/api-key";

@Controller("projects")
export class ProjectsController {
  @Public()
  @Get("health")
  health() {
    return { ok: true };
  }

  @Get()
  list(@CurrentUser() user: AuthUser) {
    return { owner: user.id };
  }

  @OptionalAuth()
  @Get("feed")
  feed(@CurrentSession() session: AuthSession | null) {
    return { signedIn: session !== null };
  }

  @AcceptPrincipals("session", "api-key")
  @Get("export")
  export(@CurrentPrincipal() principal: AuthPrincipal) {
    return { kind: principal.kind, userId: principal.userId };
  }

  @RequireAuth({ authoritative: true })
  @Delete(":id")
  remove() {
    return { removed: true };
  }
}

| Decorator | Effect | | --------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | @Public() | No principal I/O. A presented credential is not inspected, and principal readers return null. Principal parameter decorators on the same handler fail boot. | | @OptionalAuth() | Resolves a principal when credentials are present; a missing credential is anonymous, an invalid one is still denied. | | @RequireAuth({ authoritative }) | Requires a principal (the default access). authoritative: true bypasses Better Auth's cookie cache for that route, so a revoked session fails on the next request. | | @AcceptPrincipals(...kinds) | Principal kinds the route admits, in addition to the kinds its requirements name. Without it, a route admits the kinds of sources with default acceptance: sessions only. A source whose kinds a route does not admit is not consulted. | | @UseAuthInstance(name) | Evaluates the handler against a named instance. | | @UseBetterAuth() | Applies the guard and the scope interceptor locally, for applications with globalGuard: false and for gateways. | | @Require(...), @SkipDefaultRequirements() | Authorization requirements; see Authorization. | | @ForwardAuthCookies(), @SkipOriginCheck() | Direct-call cookie forwarding and origin-check opt-out; see Direct auth.api calls and Origin checks. |

The method level wins over the class level; @Public() or @OptionalAuth() on a method is a visible opt-out of class-level requirements. The boot summary counts public, optional and inheriting handlers and handlers that skip default requirements.

Failures answer 401 UNAUTHENTICATED, 403 FORBIDDEN or 429 RATE_LIMITED with a reason such as Better Auth's error code. On HTTP the body is { statusCode, error, code, reason?, message }, with WWW-Authenticate and Retry-After headers where applicable; errors.map(failure, context, transport) replaces the thrown object per instance. Infrastructure failures (storage, network, Better Auth 5xx) throw BetterAuthInfrastructureError (message Authentication service unavailable, redacted cause) and are never reported as 401 or 403. The cause is redacted of the request's cookie values, Authorization value and principal sources' credential headers, including when a principal source or policy threw the error itself; with errors.exposeRawCause: true, getRawCause(error) returns the unredacted cause for local debugging. Request-time configuration errors throw BetterAuthConfigurationError with the fixed message Authentication is misconfigured; its code, detail and hint are logged, not sent. Nest's exception layer answers both with a 500.

Readers

@CurrentSession() returns the Better Auth session, @CurrentUser() its user, and @CurrentPrincipal() the principal of any kind (AuthPrincipal, narrowed by kind). All three return null on an optional route without a principal. @CurrentSession() and @CurrentUser() accept only session principals: a route that could admit another kind fails boot with PRINCIPAL_PARAM_CONFLICT. definePrincipalParam({ kind, reason, project }) builds kind-constrained parameter decorators of the same shape, and defineInvocationParam(slot, { missing }) exposes a value a policy published for the current invocation.

BetterAuthService.getSession() and getPrincipal() return what the guard recorded for the current handler; they never start a resolution and cost no I/O. They return null in public handlers. getSession() throws SESSION_REQUIRED for an authenticated principal of another kind; use getPrincipal() and narrow kind on mixed-kind routes. Outside a handler scope (middleware, other guards, background jobs) they throw NO_AUTH_SCOPE, and in a handler the guard never ran for they throw NO_AUTH_RESULT.

Guards and interceptors read the principal with principalFor(context). It is synchronous and returns a PrincipalReading: { outcome: "no-identity" } on public plans, otherwise the recorded authenticated, absent or rejected result, each tagged with its instance:

import {
  type CanActivate,
  type ExecutionContext,
  Inject,
  Injectable,
} from "@nestjs/common";
import {
  BetterAuthService,
  getBetterAuthServiceToken,
} from "nestjs-slightly-better-auth";

@Injectable()
export class VerifiedEmailGuard implements CanActivate {
  constructor(
    @Inject(getBetterAuthServiceToken())
    private readonly auth: BetterAuthService,
  ) {}

  canActivate(context: ExecutionContext): boolean {
    const reading = this.auth.principalFor(context);
    if (reading.outcome === "no-identity") {
      return true;
    }
    return (
      reading.outcome === "authenticated" &&
      reading.principal.kind === "session" &&
      reading.principal.session.user.emailVerified
    );
  }
}

A guard that reads the principal must run after BetterAuthGuard, or principalFor() throws PRINCIPAL_READ_BEFORE_GUARD. Nest runs an APP_GUARD declared in AppModule before the global guard that BetterAuthModule.forRoot() contributes, so register such a guard with @UseGuards(), in a module imported after BetterAuthModule, or after your own { provide: APP_GUARD, useExisting: BetterAuthGuard } with globalGuard: false. The boot summary prints the global guards in the order Nest runs them.

Authorization

Requirements are values that carry a policy. @Require(...requirements) appends them to the class or method; stacked decorators and base-class requirements accumulate instead of overwriting each other. Every required plan evaluates the instance's defaultRequirements first, then class requirements (base class first), then method requirements, and stops at the first denial. anyOf() allows when any child allows and allOf() groups requirements inside anyOf(). An empty anyOf() or allOf(), for example one built from an empty configured list, fails planning with EMPTY_REQUIREMENT_GROUP instead of allowing or denying every caller.

import { Controller, Get, Post } from "@nestjs/common";
import {
  anyOf,
  Require,
  RequireFreshSession,
} from "nestjs-slightly-better-auth";
import {
  permission,
  RequirePermission,
} from "nestjs-slightly-better-auth/admin";
import {
  ActiveOrganizationId,
  fromParam,
  orgPermission,
  RequireOrgMember,
} from "nestjs-slightly-better-auth/organization";

@RequireFreshSession()
@Controller("members")
export class MembersController {
  @RequirePermission({ user: ["list"] })
  @Get()
  list() {
    return [];
  }

  @Require(
    anyOf(
      permission({ user: ["ban"] }),
      orgPermission(
        { member: ["delete"] },
        { organization: fromParam("orgId") },
      ),
    ),
  )
  @Post(":orgId/remove")
  remove() {
    return { removed: true };
  }

  @RequireOrgMember()
  @Get("active")
  active(@ActiveOrganizationId() organizationId: string) {
    return { organizationId };
  }
}

| Requirement (entry) | Delegates to | Principals | Denials | | ---------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | | freshSession({ maxAgeSeconds }), @RequireFreshSession() (.) | the session's createdAt against maxAgeSeconds or Better Auth's session.freshAge | session | 403 SESSION_NOT_FRESH | | permission(p, { principals }), @RequirePermission() (./admin) | auth.api.userHasPermission with the user id and no headers, so Better Auth reads the stored user's role, defaultRole and adminUserIds; the route reads identity authoritatively | session; opt in to more kinds | 403 MISSING_PERMISSION; delegated principals: 401 USER_BANNED or USER_NOT_FOUND, 403 INSUFFICIENT_SCOPE, 403 USER_REQUIRED for organization-owned keys | | orgPermission(p, { organization }), @RequireOrgPermission() (./organization) | auth.api.hasPermission with the request's headers and an explicit organization id | session | 403 with the organization reference's missing reason, Better Auth's membership code or MISSING_PERMISSION | | orgMember({ organization }), @RequireOrgMember() (./organization) | auth.api.getActiveMemberRole for the organization id | session | 403 with the missing reason or YOU_ARE_NOT_A_MEMBER_OF_THIS_ORGANIZATION | | apiKeyPermission(p), @RequireApiKeyPermission() (./api-key) | the verified key's own permissions through Better Auth's access-control role().authorize() | api-key (admits keys by itself) | 403 MISSING_PERMISSION |

Permission maps are typed from the registered instance's access-control statements, so unknown resources and actions are compile errors, and each requirement's plugin (admin, organization, api-key) is checked at startup (PLUGIN_PREREQUISITE). Empty permission maps fail boot. Named instances pass the instance name as a type argument, for example permission<"admin">({ ... }). The admin policy calls Better Auth without request headers, so an instance with a dynamic baseURL.allowedHosts configuration needs a fallback (DYNAMIC_BASE_URL_WITHOUT_FALLBACK).

Organization references. The organization policies always send an explicit organization id. activeOrganization() (the default) reads session.activeOrganizationId; with Better Auth's custom-session plugin and a session shape without it, it asks getActiveMember. fromParam(name) reads a route parameter, GraphQL argument or WebSocket/RPC payload field, fromHeader(name) reads a header, and organizationRef(fn, { missingReason }) wraps any function. Only a non-empty string counts as an organization id; anything else denies with the reference's missingReason (NO_ACTIVE_ORGANIZATION for the active organization, ORGANIZATION_REQUIRED otherwise) without calling Better Auth. Slugs are not ids: map a slug to an id in your own organizationRef(), for example through auth.api.listOrganizations({ headers }). Startup warns with W_ORG_PARAM_IGNORED when a handler takes an orgId-like input while its requirement checks the active organization, and with W_ORG_PARAM_MISSING when fromParam() names an input the handler does not have. @ActiveOrganizationId() and @ActiveMemberRole() return the organization id and member role the policy resolved for the current invocation. Only requirements that contributed to the allow decision publish them. When two of a handler's requirements resolve different organizations, for example defaultRequirements: [orgMember()] on the active organization and an orgPermission() with fromParam(), both decorators throw AMBIGUOUS_INVOCATION_VALUE (a generic 500) instead of choosing one; read the organization from the route, or check one organization per handler.

API keys and delegation. apiKeyPrincipal({ header, configId, references, outageProbe, acceptance }) is a principal source for Better Auth's API-key plugin. It reads x-api-key by default, calls verifyApiKey once per request and only on routes that admit "api-key", and never exposes the raw key. Its acceptance is explicit: a route admits keys through @AcceptPrincipals("session", "api-key") or a requirement that names the kind, such as @RequireApiKeyPermission(). Each verification counts against the key's usage and rate limit; RATE_LIMITED answers 429 with Retry-After, USAGE_EXCEEDED answers 429, and KEY_NOT_FOUND, KEY_DISABLED, KEY_EXPIRED and INVALID_API_KEY answer 401. Keys that belong to an organization need references: "organization" (or a function of the key's configId), because the plugin object does not expose its configuration.

An API-key principal is delegated: it acts with a narrower grant than its owner. A policy that does not name the principal's kind never judges it; the requirement denies 403 PRINCIPAL_NOT_SUPPORTED, so a user-level policy never extends a key to its owner's full rights. permission(p, { principals: ["session", "api-key"] }) opts keys into an admin check: the policy reads the owner's stored user row on every request, denies an owner under an active ban (banUser revokes sessions but not API keys), evaluates the owner's stored role, and additionally requires the key's own permissions. The organization policies never accept API keys, because Better Auth's organization endpoints need a session.

With apiKey({ enableSessionForAPIKeys: true }), Better Auth itself turns a key into a user session on every endpoint that sees the header. Such keys authenticate ordinary routes as session principals with the owner's full rights and spend their quota on every Better Auth call that carries them, including each organization check; authoritative routes and the admin policy reject them. When the API-key plugin is present, the session unit's boot advice (W_API_KEY_FULL_SESSION, and W_API_KEY_SESSION_MULTIPLIER listing the policies that present the request's credentials) describes this, worded conditionally by default. session: { apiKeySessions: true } states that a configuration enables the option and words the advice as fact; session: { apiKeySessions: false } states that none does and drops the advice. Prefer apiKeyPrincipal() with @RequireApiKeyPermission() for key traffic.

Default requirements. defaultRequirements: [orgMember()] adds a company-wide rule to every required plan of the instance. @SkipDefaultRequirements() opts one handler or controller out of the defaults while keeping its own requirements; startup fails with UNSATISFIABLE_PRINCIPAL_KINDS when the AND-ed requirements of a plan admit no common principal kind.

Custom policies. definePolicy({ id, requires, evaluate, validate }) returns a requirement builder. Policies return allow() or deny({ reason, status }) and throw only for infrastructure faults. context.memo(key, compute) deduplicates Better Auth calls per logical request; limits.maxAuthorizationCallsPerRequest (default 100, false disables it) bounds the distinct memoized calls of one request, past which a requirement answers 429 TOO_MANY_AUTHORIZATION_CHECKS without calling Better Auth. For DI-backed policies use requirement(PolicyClass, params). A policy call that meets Better Auth's generic 401 for a session principal re-reads the session once: a session that still resolves turns the failure into a 5xx, a session that ended into 401; a 401 with another code becomes a 403 denial.

Decisions are made per invocation: each GraphQL field, alias and WebSocket message gets its own decision, while Better Auth I/O is shared per logical request.

Hooks

Hook providers are ordinary singleton providers; any provider in the application can declare hooks, without a class marker. Hooks bind to the default instance unless instance names another one.

import { Injectable } from "@nestjs/common";
import { APIError } from "better-auth/api";
import {
  AfterAuth,
  AfterDatabase,
  type AuthAfterHookContext,
  type AuthHookContext,
  BeforeAuth,
  BeforeDatabase,
  type DatabaseHookData,
} from "nestjs-slightly-better-auth";

@Injectable()
export class AccountHooks {
  @BeforeAuth("/sign-up/email")
  checkDomain(ctx: AuthHookContext<"/sign-up/email">) {
    const email = ctx.body.email;
    if (typeof email !== "string" || !email.endsWith("@example.com")) {
      throw new APIError("FORBIDDEN", { message: "Unsupported domain" });
    }
  }

  @AfterAuth(["/sign-in/email", "/sign-in/social", "/callback/:id"], {
    calls: "http",
    skipInternal: true,
  })
  signedIn(
    ctx: AuthAfterHookContext<
      "/sign-in/email" | "/sign-in/social" | "/callback/:id"
    >,
  ) {
    console.log("sign-in", ctx.context.newSession?.user.id);
  }

  @BeforeDatabase("user.create")
  normalize(user: DatabaseHookData<"user.create">) {
    return { data: { name: user.name.trim() } };
  }

  @BeforeDatabase("user.delete")
  protectOwner(user: DatabaseHookData<"user.delete">) {
    return user.email !== "[email protected]";
  }

  @AfterDatabase("session.create")
  audit(session: DatabaseHookData<"session.create", "after">) {
    console.log("session", session.userId);
  }
}

| Decorator | Signature | Return value | | ----------------------------------- | ------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | | @BeforeAuth(match?, options?) | (ctx: AuthHookContext<P>) => unknown | undefined continues; { context } merges into the endpoint input; any other object short-circuits and becomes the response; a thrown APIError rejects | | @AfterAuth(match?, options?) | (ctx: AuthAfterHookContext<P>) => unknown | undefined keeps the result; another value replaces ctx.context.returned; a thrown APIError replaces the response and later hooks still run | | @BeforeDatabase(target, options?) | (data: DatabaseHookData<E>, ctx: GenericEndpointContext \| null \| undefined) => DatabaseHookResult<E, "before"> | create and update: void, false (abort) or { data } (merged); delete: void or false only | | @AfterDatabase(target, options?) | (data: DatabaseHookData<E, "after">, ctx: GenericEndpointContext \| null \| undefined) => void | ignored; runs after the write commits |

A decorated method may declare fewer parameters than the signature and may return a narrower result. match is an endpoint route pattern such as "/callback/:id" (not the concrete URL), an array of patterns or a predicate (ctx) => boolean; without it, the hook runs for every endpoint. Patterns autocomplete from the registered instance and are validated at startup with a "did you mean" (UNKNOWN_HOOK_PATH). Database targets are user, session, account and verification with create, update or delete.

Hook methods receive Better Auth's own context objects. Before-hook bodies are typed unknown per field, because Better Auth validates the body only after before-hooks ran. Update payloads are partial, and every update hook sees the original payload while create hooks see the data earlier hooks returned. A database hook receives null or undefined as its context outside an endpoint. As in Better Auth, ctx.setCookie() and ctx.setHeader() in a before-hook have no effect when the endpoint runs; set response cookies and headers in @AfterAuth().

HookOptions are calls ("all" by default, "http" for requests routed through the mount, "server" for direct auth.api calls), skipInternal (default false: hooks also run for the library's own calls, such as the guard's getSession; set it for audit hooks), order (ascending, default 0) and instance. Database hooks take order and instance.

Security rules belong where every path passes. A @BeforeAuth("/get-session") rule sees the original request and fails open for bearer and API-key callers, and an @AfterAuth("/get-session") rule applies to Nest routes and direct getSession calls only, because Better Auth's own endpoints read the session without dispatching hooks. Rules that must hold everywhere change state Better Auth consults on every path: Better Auth's admin ban, session revocation, or a @BeforeDatabase("session.create") hook.

Direct auth.api calls

A direct auth.api.* call in a handler behaves as in plain Better Auth: its Set-Cookie does not reach the caller's response. service.headersFrom(request) returns the platform request's headers with the client-IP header set and HTTP/2 pseudo-headers removed, for direct calls on the caller's behalf.

Forwarding cookies. @ForwardAuthCookies() on a handler or controller (and @ForwardAuthCookies(false) to opt one handler back out), or cookies.forwardDirectCalls: true for a whole instance (W_FORWARD_DIRECT_CALLS), forwards the Set-Cookie of direct calls that carry no credential, such as a sign-in, or the caller's own credential (session cookie, Authorization and the principal sources' credential headers). Calls with another credential have their cookies dropped. service.forwardForeignCookies(fn) forwards every call inside fn, for deliberate account switching, and throws FORWARDING_NOT_DECLARED in a handler without forwarding. Cookies from the library's own calls (principal sources and policies) always reach the caller, so session refreshes during authorization are delivered. The Express and Fastify cookie sinks skip a line identical to the latest line already set for the same cookie, so a source that also appends a call's cookies itself does not deliver them twice. A handler that already sent its response through @Res() drops them.

A forwarding handler is in form mode: the guard enforces Better Auth's form-CSRF rule before the handler runs, on every HTTP method and GraphQL operation kind, with or without an inbound cookie, and for public and optional handlers too. A forwarding handler therefore needs a guard that reaches it, even when it is public; startup reports forwarding handlers that no guard covers.

import { Body, Controller, Inject, Post, Req } from "@nestjs/common";
import {
  BetterAuthService,
  ForwardAuthCookies,
  getBetterAuthServiceToken,
  OptionalAuth,
} from "nestjs-slightly-better-auth";

@Controller("session")
export class SessionController {
  constructor(
    @Inject(getBetterAuthServiceToken())
    private readonly auth: BetterAuthService,
  ) {}

  @OptionalAuth()
  @ForwardAuthCookies()
  @Post("sign-in")
  signIn(
    @Req() request: unknown,
    @Body() body: { email: string; password: string },
  ) {
    return this.auth.api.signInEmail({
      body,
      headers: this.auth.headersFrom(request),
    });
  }
}

Rate limits and origin checks on direct calls. Better Auth applies its rate limiter and its own origin check only to requests routed through auth.handler. A direct signInEmail, signUpEmail or password call from a handler bypasses both. Prefer letting clients call Better Auth's routes through the mount for sign-in, sign-up and password flows, keep direct credential calls for trusted back-office code, and throttle proxy handlers in the application. The form-mode origin rule above protects forwarding handlers against login CSRF; it does not replace the rate limiter.

Caller-session check. A direct call that carries the caller's own session cookie on a browser leg must have a passing origin verdict for that leg and instance. Otherwise the call fails before the endpoint runs with PUBLIC_HANDLER_USED_CALLER_SESSION (a generic 500 with a logged hint). This covers public handlers, which run no origin check, safe methods such as a GET /logout proxy, and handlers the guard never ran for. Calls without the caller's session cookie (a sign-up without headers, bearer or API-key calls), calls with a session cookie that the transport mapped in-band rather than the browser leg's own cookie (graphql-ws connection parameters, WebSocket credentials), cookie-less transports and @SkipOriginCheck() handlers are unaffected. A cross-site state-changing proxy therefore needs a state-changing method, @OptionalAuth() or a stricter access mode, @ForwardAuthCookies() when it returns auth cookies, and a trusted Origin.

Scopes. The global scope interceptor opens a scope for every handler a transport recognizes, independently of globalGuard. The scope carries the cookie bridge, refresh suppression on cookie-less transports and the caller-session check. globalScope: false removes it and logs W_NO_GLOBAL_SCOPE. Code outside a handler scope (middleware, guards, policies, background jobs, GraphQL field resolvers without the "interceptors" enhancer) gets plain Better Auth semantics. service.withoutCookies(fn) runs calls with no cookie capability, which also suppresses session refresh, and service.runOutsideScope(fn) runs fn as if no scope existed: no forwarding, no refresh suppression and no caller-session check, so an unsafe public handler that uses it is open to cross-site requests.

Origin checks

Better Auth's origin check protects cookie-carrying requests on its own routes only. The guard applies the same rule to application routes, GraphQL operations and WebSocket messages through originCheck.

  • Cookie mode (originCheck.mode: "cookie", the default). An unsafe operation whose browser leg carries a cookie must come from a trusted origin, whatever credential authenticated it: HTTP methods other than GET, HEAD and OPTIONS, GraphQL mutations over HTTP, every WebSocket message and every GraphQL operation over a WebSocket. Denials are 403 with reason INVALID_ORIGIN or MISSING_OR_NULL_ORIGIN.
  • Advisory verdict on safe methods. In cookie mode, a guarded safe request that carries a cookie also computes the origin verdict, without denying the request. An untrusted origin still reads its data; only a later direct call with the caller's session cookie consults the verdict and fails. Browsers send no Origin on same-origin GET requests, and Referrer-Policy: no-referrer also removes Referer; the advisory verdict then takes the leg's own origin from Sec-Fetch-Site: same-origin and checks it against the trusted origins, as it does for Origin: null. Enforcing checks keep Better Auth's rule. Public handlers compute no verdict.
  • Form mode (forwarding handlers). Always enforced, on every method and operation kind, following Better Auth's form-CSRF rule: with a cookie, the cookie-mode rule; without one, a cross-site navigation is denied with CROSS_SITE_NAVIGATION_LOGIN_BLOCKED, a present Origin or Referer must be trusted, and a request without any browser signal is allowed.

Trusted origins are the instance's post-init trustedOrigins, including plugin-contributed ones and function-valued trustedOrigins, matched with Better Auth's own matcher. As on Better Auth's router, a function-valued trustedOrigins contributes only its result for the request, never the no-request result Better Auth computes at startup. The request's own origin counts as trusted only when baseURL is unset, never from forwarding headers. A verdict is memoized per browser leg, so a socket evaluates a function-valued trustedOrigins once. The first denial per reason and origin within an hour logs a warning with the sanitized origin.

Escapes for non-browser and native clients.

  • Server-side callers that forward a user's cookie on unsafe requests, such as an SSR server, send a trusted Origin header, as Better Auth's own routes require. Node's built-in fetch sends none by default.
  • Machine clients use bearer tokens or API keys without cookies; cookie mode does not inspect them.
  • Native apps that send the session cookie themselves (Better Auth's Expo client calling your routes) send no Origin, Referer or Sec-Fetch-* headers. Either set originCheck.missingOrigin: "allow-non-browser", which admits requests without all three signals, or send Origin: <app scheme>:// and list it in trustedOrigins. With the Expo plugin present and missingOrigin: "reject", startup logs