Authentication
@blixis-io/auth verifies bearer JWTs and checks roles, built entirely on primitives Guards & Authorization and Request Context already introduced — it’s app-layer, not a framework dependency: @blixis-io/http has no idea @blixis-io/auth exists.
By default this is verification only — @blixis-io/auth starts from “here’s a bearer token,” not “here’s a password.” Pass issuing to forRoot() to also get password sign-in, refresh-token rotation, and sign-out — see Issuing tokens below.
Why it’s a factory, not a fixed token
Section titled “Why it’s a factory, not a fixed token”Like @blixis-io/config, every app’s JWT payload shape is different — there’s no single fixed type to validate against. So @blixis-io/auth exports a function, defineAuthModule, that builds a guard bound to your own Zod schema:
import { defineAuthModule } from "@blixis-io/auth";import { z } from "zod";
const ClaimsSchema = z.object({ sub: z.string(), roles: z.array(z.string()),});
export const { AuthModule, JwtAuthGuard, createRolesGuard, getCurrentUser } = defineAuthModule(ClaimsSchema);Verifying a token
Section titled “Verifying a token”@Module({ imports: [AuthModule.forRoot({ secret: process.env.JWT_SECRET! })] })class PostsModule {}@UseGuards(JwtAuthGuard)@Get("me")me() { // ...}JwtAuthGuard reads the Authorization header (the Bearer scheme, in any letter case, as RFC 7235 allows), verifies the token (HMAC — HS256 by default, or HS384/HS512) against the secret from forRoot(), and validates the decoded payload against your claims schema. A token must carry an exp claim: one that never expires is refused even with a valid signature. Any failure — missing header, bad signature, expired token, a payload that fails the schema — throws UnauthorizedException (a real 401), never a plain false: a bad token is a client authentication failure, not a generic “denied,” so it gets its own status rather than folding into a guard’s usual 403. The response carries a WWW-Authenticate challenge as RFC 9110 requires: Bearer when no token was sent, Bearer error="invalid_token" when one was sent and failed (RFC 6750). Sign-in (AuthService.signIn) returns a plain 401 with no challenge, since its credentials travel in the request body rather than an Authorization header.
The secret, the issuer and the audience
Section titled “The secret, the issuer and the audience”forRoot() refuses a weak secret at boot with AuthConfigError: at least 32 bytes for HS256, 48 for HS384, 64 for HS512 (RFC 7518, section 3.2; counted in bytes of the UTF-8 text). The error gives the length, never the value. Generate one at random, for example openssl rand -base64 48 (64 characters, enough for every algorithm), and keep it in the environment, not in the repository.
Two optional settings stop one app’s tokens being accepted by another that happens to share a secret (staging and production, two services):
AuthModule.forRoot({ secret: process.env.JWT_SECRET!, issuer: "https://auth.example.com", // the token's `iss` must be exactly this audience: "orders-api", // the token's `aud` must list this (or any of them, if you pass an array)});When set, a token without the claim or with a different value is a 401, and tokens issued by AUTH_SERVICE carry them. When unset, iss and aud are neither checked nor added.
On success, the verified claims are stored in RequestContext for the rest of the request. Read them back with getCurrentUser:
@Injectable()class PostsService { constructor(private readonly ctx: RequestContext) {}
create(input: CreatePostInput) { const user = getCurrentUser(this.ctx); // { sub: string; roles: string[] } | undefined // ... }}getCurrentUser returns undefined outside a request, or inside one where JwtAuthGuard hasn’t run (or wasn’t applied to this route) — same honest-undefined philosophy as RequestContext.get() itself.
Role checks with createRolesGuard
Section titled “Role checks with createRolesGuard”Role-based access is a second guard, not a decorator — consistent with this framework’s guard-is-the-authorization-primitive philosophy (see Guards & Authorization) rather than introducing a parallel @Roles() metadata system:
export const AdminGuard = createRolesGuard("admin");@Module({ imports: [AuthModule.forRoot({ secret: process.env.JWT_SECRET! })], providers: [AdminGuard],})class PostsModule {}@UseGuards(JwtAuthGuard, AdminGuard)@Delete(":id")remove(@Param("id") id: string) { // ...}createRolesGuard(...roles) returns a fresh class each call — same shape as defineConfigModule returning an app-specific class from a closure. Assign it to a named export and list it in providers, exactly like any other guard — leaving a guard class out of providers is the single most common mistake with @UseGuards, see Guards & Authorization.
AdminGuard must run after JwtAuthGuard in the same @UseGuards(...) list: guards run sequentially and short-circuit, so by the time AdminGuard checks getCurrentUser(ctx).roles, JwtAuthGuard has already populated it. If no user is in RequestContext yet — guards misordered, or JwtAuthGuard left off entirely — AdminGuard throws UnauthorizedException rather than silently returning false, since “no identity at all” and “identity, but wrong role” are different failures worth telling apart.
A user with none of the required roles gets a plain false — same as any other guard denial — which the framework turns into 403 Forbidden.
@Roles and @Public, and protecting everything
Section titled “@Roles and @Public, and protecting everything”createRolesGuard needs a guard class per role set, registered by hand and ordered after the auth guard. The decorators do the same job with less ceremony. AuthGuard (returned by defineAuthModule) authenticates the request, then enforces @Roles(...) if the route has one:
@Controller("admin")@UseGuards(AuthGuard)export class AdminController { @Get("users") @Roles("admin", "support") // at least one of these users() { /* ... */ }
@Get("ping") @Public() // no token needed ping() { return "pong"; }}An unauthenticated request is a 401, a request without the role is a 403. @Roles on a controller covers every route in it; a route’s own @Roles replaces it, and @Public() on a route overrides everything above it.
To make every route require a token without touching each controller, opt in with protectAllRoutes:
AuthModule.forRoot({ secret: process.env.JWT_SECRET!, protectAllRoutes: true })Now an undecorated route answers 401 without a token, and the routes that must stay open say so with @Public(): your login and refresh routes, a health check. That is the point of the default: a controller added later can’t be forgotten and left open. It is off by default, so enabling it is a deliberate change; until you do, nothing about existing routes changes, and @Roles only takes effect on routes that carry @UseGuards(AuthGuard).
Things to know:
@Public()skips authentication entirely, so a valid token sent to a public route is not read andgetCurrentUserisundefinedthere.- Roles come from the token’s
rolesclaim, which must be an array of strings. A token without it fails any@Rolescheck with403. protectAllRoutesuses the@GlobalGuard()mechanism from@blixis-io/http(see Guards & Authorization). Routes mounted withapp.mount(), such asserveOpenApi, bypass guards and stay public.JwtAuthGuardandcreateRolesGuardare unchanged and still work.
global is off by default
Section titled “global is off by default”AuthModule.forRoot({ secret, global: true })Same default as @blixis-io/db’s DrizzleModule, for the same reason: most apps only need JwtAuthGuard in the modules that actually have protected routes, so encapsulation is the better default. Pass global: true if most of your app sits behind auth.
API keys, for machines
Section titled “API keys, for machines”A service or script needs a credential that does not expire in minutes and can be turned off on its own. Pass apiKeys to forRoot() and the same module also accepts an x-api-key header, checked against a store you write. A key resolves to the same claims as a token, so @Roles, getCurrentUser and tenancy work unchanged.
AuthModule.forRoot({ secret, protectAllRoutes: true, apiKeys: { store: MyApiKeyStore, scopedRoutesOnly: true } });@RequireScopes("projects:read") // a key must hold every scope a route lists, or it gets a 403@Controller("projects")class ProjectsController {}The rules worth knowing before you read the guide:
x-api-keypresent means it is the credential. A bad key is a401and never falls back to theAuthorizationheader.- Every failure is the same
401; a store that fails is a503, never an allow. - Scopes limit keys only. A person’s permissions come from roles and membership. With
scopedRoutesOnly: truea key is refused on any route that declares no scopes. JwtAuthGuardaccepts tokens only;AuthGuard(whatprotectAllRoutesapplies) accepts both and enforces scopes.- Only a SHA-256 of the secret is stored; it is compared in constant time.
Creating, rotating, revoking, logging and limiting keys by network: API keys for machines. Every option: reference.
Issuing tokens
Section titled “Issuing tokens”Pass issuing to forRoot() to turn on AUTH_SERVICE — password sign-in, refresh-token rotation, and sign-out. @blixis-io/auth stays storage-agnostic: you implement two small interfaces as ordinary DI classes, the package never depends on @blixis-io/db or any particular ORM.
export const { AuthModule, JwtAuthGuard, createRolesGuard, getCurrentUser, AUTH_SERVICE } = defineAuthModule(ClaimsSchema);@Module({ imports: [ AuthModule.forRoot({ secret: process.env.JWT_SECRET!, issuing: { imports: [UsersModule], // whatever exports the stores' own dependencies (e.g. DATABASE) credentialStore: DrizzleCredentialStore, refreshTokenStore: DrizzleRefreshTokenStore, }, }), ],})class AppModule {}@Injectable()class AuthController { constructor(@Inject(AUTH_SERVICE) private readonly auth: AuthService) {}
@Post("sign-in") async signIn(@Body(SignInSchema) body: SignInInput) { return this.auth.signIn(body.email, body.password); }}See the Issuing Tokens guide for a full working CredentialStore/RefreshTokenStore pair backed by Drizzle, plus a sign-up flow using hashPassword.
Password hashing
Section titled “Password hashing”import { hashPassword, verifyPassword } from "@blixis-io/auth";
const hash = await hashPassword(user.password); // "$argon2id$v=19$m=19456,t=2,p=1$<salt>$<hash>"const valid = await verifyPassword(candidatePassword, hash);Argon2id via Node’s own crypto.argon2 (no external dependency), at OWASP’s minimum recommended cost. The returned string stores its own parameters, so a future bump to the cost constants still verifies hashes minted under the old ones.
The store interfaces
Section titled “The store interfaces”interface CredentialStore<Claims> { findByIdentifier(identifier: string): Promise<{ subject: string; passwordHash: string } | null | undefined>; loadClaims(subject: string): Promise<Claims | null | undefined>;}
interface RefreshTokenStore { create(tokenHash: string, record: { subject: string; expiresAt: Date; familyId: string }): Promise<void>; find(tokenHash: string): Promise<RefreshTokenRecord | null | undefined>; markRotated(tokenHash: string): Promise<boolean>; revoke(tokenHash: string): Promise<void>; revokeAllForSubject(subject: string): Promise<void>; // optional, see "Making rotation resilient" in the Issuing Tokens guide: rotate?(oldTokenHash: string, next: { tokenHash: string; subject: string; expiresAt: Date; familyId: string }): Promise<boolean>; revokeFamily?(familyId: string): Promise<void>;}AuthService never sees a raw password or a raw refresh token in your store — it hashes both before ever calling out (Argon2id for passwords, a fast SHA-256 for the high-entropy refresh token, since the latter doesn’t need to be slow to resist brute force). loadClaims returning null/undefined means “this account can’t sign in right now” (gone or disabled) and fails exactly like a wrong password — your store owns that decision, @blixis-io/auth just fails closed on it.
Fail-closed rules, deliberate
Section titled “Fail-closed rules, deliberate”signInnever reveals whether an identifier exists. An unknown identifier still runs a real password verification (against an internally cached dummy hash) before rejecting, so response timing doesn’t leak account existence. Unknown identifier, wrong password, and a disabled account (loadClaimsreturning null) all throw the identicalUnauthorizedException("Invalid credentials").- Refresh tokens rotate on every use.
refresh()invalidates the presented token and issues a new one. Presenting an already-rotated token — real reuse, or two callers racing to refresh the same token — revokes that login and throws, on the theory that only the rightful client should ever hold the newest token. “That login” is its family (every token descended from one sign-in) if your store implementsrevokeFamily, so the user’s other devices stay signed in; otherwise it is every refresh token of the subject. WithrefreshReuseGraceSecondsset, a token rotated within that window is just refused, and nothing is revoked. - A failure part-way never strands the client. Everything that can fail without leaving a trace (loading the claims, signing the access token) happens before anything is written. Then the successor is stored and the old token marked rotated, atomically if your store implements
rotate; without it the successor is stored first, so a failure leaves the old token usable (and at worst an unused successor), never a client with no valid token. signOutis idempotent. Revoking an unknown or already-revoked token never throws.- A claims value that fails your own schema is a server bug, not a client error. If
CredentialStore.loadClaims()returns something yourclaimsSchemawould reject,AuthServicethrows a plainError(a500) instead of silently signing a tokenJwtAuthGuardwould later reject anyway.
What this deliberately doesn’t do yet
Section titled “What this deliberately doesn’t do yet”This is a first pass, scoped to match what a real caller needs today rather than every guarantee a production identity system eventually wants — each gap below is a deliberate, named deferral:
- Sign-in is unthrottled. This is the one that matters most before going to production:
signInhas no rate limiting or lockout built in. Add it at a proxy, or with an interceptor, before shipping password sign-in for real. - The grace window is off by default. Two tabs refreshing the same token at nearly the same instant trip reuse detection and end that login (just that login, if the store keeps families; every session otherwise). Set
refreshReuseGraceSecondsto refuse the second request without revoking anything, and have clients single-flight their own refresh calls anyway. There’s also no absolute session cap; a session can slide indefinitely while actively used. - Revoking refresh tokens does not revoke access tokens already issued. A signed access token is valid until its
exp; sign-out and reuse revocation only stop new ones being minted. KeepaccessTokenTtlshort (the default is 15 minutes) for that reason. - HMAC signing only, same as verification — no asymmetric (EdDSA) signing or JWKS endpoint. Only relevant once more than one service needs to verify tokens without sharing the HMAC secret.
- No security-event hook for detected reuse — it’s logged nowhere by
@blixis-io/authitself today. Wire your own logging into your store implementations if you need it.
- Every exported symbol:
@blixis-io/authreference. - The guard primitive this is built on: Guards & Authorization.
- Where verified claims live between guards, interceptors, and the handler: Request Context.
- A full Drizzle-backed implementation: Issuing Tokens guide.
- Keys for services and scripts: API keys for machines.