Add Authentication
Protect your routes end-to-end. By the end you'll have:
POST /auth/registerandPOST /auth/loginissuing signed JWTs, with constant-time password hashing so a wrong password and a missing account can't be told apart by timing.- A request-scoped
AuthContextany handler reads the caller's id and scopes from. - The shipped
JWTAuthMiddleware, listed directly, verifying the bearer token (or cookie) before every handler. - A scope-guarded
GET /admin/statsreturning401to anonymous callers,403to under-scoped ones, and200to an admin.
Everything is wired by class identity and constructor signatures — no decorators. The one twist: the auth
primitives live in @heximon/auth, so the compiler can't read their constructors from your source; you bind
them through useFactory providers whose parameter types declare their deps — except where a framework
class's own constructor is already plain DI tokens, which needs no factory at all.
1. Generate the signing key pair
A JWT is signed with a private key and verified with the matching public key. Hold both halves of one
generated pair as a single DI token so the signer and verifier never drift apart. An async useFactory
(step 4) resolves the pair once at boot — no key literal in source.
import { Base64JWK, type GeneratedKeyPair, JwkService, JWTVerifier } from "@heximon/auth";
import { Platform } from "@heximon/runtime";
export class IdentityKeyPair {
// An EC keygen (ES256, P-256) is ~2ms versus an RSA-2048 keygen's tens-to-hundreds of ms, so the
// boot-time `useFactory` that resolves it stays off the critical path.
private static readonly algorithm: string = "ES256";
private static readonly environmentVariable: string = "HEXIMON_JWK";
public readonly privateKey: GeneratedKeyPair["privateKey"];
public readonly publicKey: GeneratedKeyPair["publicKey"];
public constructor(keyPair: GeneratedKeyPair) {
this.privateKey = keyPair.privateKey;
this.publicKey = keyPair.publicKey;
}
// HEXIMON_JWK when set (production, survives a restart/scale-out), else a freshly generated pair (dev).
public static async resolve(): Promise<IdentityKeyPair> {
const encoded = Platform.get(IdentityKeyPair.environmentVariable);
return encoded === undefined
? IdentityKeyPair.generate()
: IdentityKeyPair.fromEncoded(encoded);
}
public static fromEncoded(encoded: string): IdentityKeyPair {
const privateKey = Base64JWK.decode(encoded);
return new IdentityKeyPair({ privateKey, publicKey: JWTVerifier.toPublicJWK(privateKey) });
}
public static async generate(): Promise<IdentityKeyPair> {
return new IdentityKeyPair(await new JwkService().generate(IdentityKeyPair.algorithm));
}
}
resolve() reads a Base64-encoded private JWK from the HEXIMON_JWK environment variable when it's set —
Base64JWK.decode turns the one Base64 string back into a JWK, and JWTVerifier.toPublicJWK derives the
public half by stripping its private members. With no HEXIMON_JWK set, it falls back to generate() — a
fresh pair every boot, fine for development, but a restart or a second instance (scale-out) invalidates every
session issued so far. In production you also publish the public half at a JWKS endpoint (see
Authentication).
2. Store accounts
Login needs somewhere to look a user up by email and verify a password hash. A store is a plain provider — a class becomes injectable just by being listed in a module. A real app would back this with a database (see Build a CRUD API); an in-memory map keeps this recipe dependency-free.
export interface NewUserAccount {
readonly email: string;
readonly passwordHash: string; // the Scrypt/Pbkdf2-encoded hash, never the plaintext
readonly permissions: readonly string[];
}
export interface UserAccount extends NewUserAccount {
readonly id: string;
}
export class UserAccountStore {
private readonly accountsById = new Map<string, UserAccount>();
public create(account: NewUserAccount): UserAccount {
const stored: UserAccount = { id: String(this.accountsById.size + 1), ...account };
this.accountsById.set(stored.id, stored);
return stored;
}
public findById(id: string): UserAccount | undefined {
return this.accountsById.get(id);
}
public findByEmail(email: string): UserAccount | undefined {
for (const account of this.accountsById.values()) {
if (account.email === email) return account;
}
return undefined;
}
public hasEmail(email: string): boolean {
return this.findByEmail(email) !== undefined;
}
}
3. Issue tokens on login
register hashes the password and grants the default scope. login verifies the password in constant
time, then signs a payload carrying the account's scopes — they travel inside the token, so the verifier
needs no database lookup later. me reads the principal the middleware established for the request.
The controller injects the abstract PasswordHashingAlgorithm port (not a concrete Scrypt), so an edge
build can swap in Pbkdf2 with no controller change, and the shipped JWTAuthMiddleware runs at the
controller level — its constructor is plain DI tokens, so it's listed directly, no app-owned wrapper needed.
import { AuthContext, JWTAuthMiddleware, JWTFactory, PasswordHashingAlgorithm } from "@heximon/auth";
import type { Controller, Get, Post } from "@heximon/http";
import type { UserAccount } from "./user-account-store";
import { UserAccountStore } from "./user-account-store";
interface Credentials {
readonly email: string;
readonly password: string;
}
export class AuthController implements Controller<{ prefix: "/auth"; middlewares: [JWTAuthMiddleware] }> {
private static readonly minimumPasswordLength: number = 8;
private static readonly tokenLifetimeMilliseconds: number = 60 * 60 * 1000; // one hour
private static readonly defaultPermissions: readonly string[] = ["profile:read"];
public constructor(
private readonly accounts: UserAccountStore,
private readonly passwordHashing: PasswordHashingAlgorithm,
private readonly tokenFactory: JWTFactory,
private readonly authContext: AuthContext,
) {}
public async register(action: Post<"/register">): Promise<Response> {
const credentials = AuthController.readCredentials(await action.request.readBody());
if (!credentials) {
return action.respond(400, { error: "An email and an 8+ character password are required." });
}
if (this.accounts.hasEmail(credentials.email)) {
return action.respond(409, { error: "That email is already registered." });
}
const account = this.accounts.create({
email: credentials.email,
passwordHash: await this.passwordHashing.hash(credentials.password),
permissions: AuthController.defaultPermissions,
});
return action.respond(201, { id: account.id, email: account.email });
}
public async login(action: Post<"/login">): Promise<Response> {
const credentials = AuthController.readCredentials(await action.request.readBody());
if (!credentials) {
return action.respond(400, { error: "An email and a password are required." });
}
const account = this.accounts.findByEmail(credentials.email);
if (!account) {
// Run a dummy hash so a missing account is indistinguishable (by timing) from a wrong password.
await this.passwordHashing.simulate();
return action.respond(401, { error: "Invalid credentials." });
}
if (!(await this.passwordHashing.verify(account.passwordHash, credentials.password))) {
return action.respond(401, { error: "Invalid credentials." });
}
const expiresAt = new Date(Date.now() + AuthController.tokenLifetimeMilliseconds);
const token = await this.tokenFactory.create(
{ sub: account.id, permissions: [...account.permissions] },
expiresAt,
);
// Deliver the token both ways: an HttpOnly cookie the middleware reads back, and the body for an
// `Authorization: Bearer` client (curl, the test suite, another service).
action.response.setCookie("accessToken", token.value, {
httpOnly: true,
sameSite: "lax",
secure: false,
expires: expiresAt,
});
return action.respond(200, {
userId: account.id,
accessToken: token.value,
expiresAt: expiresAt.toISOString(),
});
}
public async me(action: Get<"/me">): Promise<Response> {
const userId = this.authContext.subject(); // set by JWTAuthMiddleware when a valid token was sent
if (userId === undefined) {
return action.respond(401, { error: "Authentication is required." });
}
const account = this.accounts.findById(userId);
return account === undefined
? action.respond(401, { error: "The authenticated account no longer exists." })
: action.respond(200, AuthController.toProfile(account));
}
private static readCredentials(body: unknown): Credentials | undefined {
if (typeof body !== "object" || body === null) return undefined;
const { email, password } = body as Record<string, unknown>;
if (typeof email !== "string" || !email.includes("@")) return undefined;
if (typeof password !== "string" || password.length < AuthController.minimumPasswordLength) {
return undefined;
}
return { email, password };
}
private static toProfile(account: UserAccount): { id: string; email: string; permissions: readonly string[] } {
return { id: account.id, email: account.email, permissions: account.permissions };
}
}
JWTAuthMiddleware lets an anonymous request through (storing a null principal) and only
hard-rejects a present-but-invalid token with 401. That's deliberate: one middleware can sit on a
controller mixing public (/register, /login) and protected (/me) routes — the handlers decide who
needs a principal.
4. Wire the auth module
The compiler recovers a class's constructor — your app's from source, a package class's from the types it
ships. JWTAuthMiddleware's constructor is only DI tokens (an AuthContext, an optional flag), so it's a
bare provider — no wrapper class needed. useFactory is reserved for construction DI can't express:
JWTFactory reads a property of the injected key pair, IdentityKeyPair resolves it through an async
static factory, and AuthContext takes a non-DI constructor argument (a CookieTokenSource). There is no
separate JWTVerifier provider at all: because JWTFactory extends JWTVerifier (it verifies with the same
key it signs with), providing JWTFactory alone satisfies every JWTVerifier-typed injection and export.
import {
AuthContext,
CookieTokenSource,
JWTAuthMiddleware,
JWTFactory,
JWTVerifier,
PasswordHashingAlgorithm,
Scrypt,
} from "@heximon/auth";
import { Context, Module } from "@heximon/runtime";
import { AuthController } from "./auth.controller";
import { IdentityKeyPair } from "./identity-key-pair";
import { UserAccountStore } from "./user-account-store";
export class AuthModule extends Module({
providers: [
UserAccountStore,
{ provide: PasswordHashingAlgorithm, useClass: Scrypt }, // a port, so an edge app swaps Pbkdf2 with no code change
JWTAuthMiddleware, // bare: ctor is plain DI tokens (AuthContext + a default flag)
{ provide: IdentityKeyPair, useFactory: () => IdentityKeyPair.resolve() }, // async, awaited at boot
{ provide: JWTFactory, useFactory: (keys: IdentityKeyPair) => new JWTFactory(keys.privateKey) },
{
provide: AuthContext,
useFactory: (context: Context, verifier: JWTVerifier) =>
new AuthContext(context, verifier, new CookieTokenSource("accessToken")),
},
],
http: { controllers: [AuthController] },
// Re-export so other feature modules authenticate with the SAME middleware and read the SAME principal.
exports: [AuthContext, JWTAuthMiddleware, JWTVerifier, JWTFactory, UserAccountStore, PasswordHashingAlgorithm],
}) {}
AuthContext's factory still asks for a JWTVerifier (the capability it actually needs to verify), and the
compiler resolves that parameter to the JWTFactory instance above — the JWTVerifier import is for that
type annotation, and for the exports list, not for a provider of its own.
5. Guard an endpoint by scope
Now the payoff: a route only an admin may reach. The admin module imports AuthModule and reuses its
exports — same middleware authenticating the request, same AuthContext carrying the principal.
import { AuthContext, JWTAuthMiddleware } from "@heximon/auth";
import type { Controller, Get } from "@heximon/http";
export class AdminController implements Controller<{ prefix: "/admin"; middlewares: [JWTAuthMiddleware] }> {
private static readonly requiredScope: string = "admin:stats:read";
public constructor(private readonly authContext: AuthContext) {}
public async stats(action: Get<"/stats">): Promise<Response> {
if (this.authContext.principal() === null) {
return action.respond(401, { error: "Authentication is required." });
}
if (!this.authContext.isAllowed(AdminController.requiredScope)) {
return action.respond(403, { error: `Requires the '${AdminController.requiredScope}' scope.` });
}
return action.respond(200, { totalRequests: 42, activeSessions: 7 });
}
}
import { Module } from "@heximon/runtime";
import { AuthModule } from "../auth/auth.module";
import { AdminController } from "./admin.controller";
export class AdminModule extends Module({
imports: [AuthModule],
http: { controllers: [AdminController] },
}) {}
This handler checks the scope itself because the route is declared inline — by the Get<"/stats">
parameter type. Inline routes carry no scope metadata, so an explicit authContext.isAllowed(...) is the
right tool, returning the same 401/403 distinction a guard would.
For a contract route, declare the scope with route.scopes(...) and let the shipped GuardMiddleware
enforce it — no per-handler check. List it in the middlewares array after JWTAuthMiddleware, since
the guard reads the principal the auth middleware stores.
import { Contract, Route } from "@heximon/contract";
import { CreateTaskBody, TaskResponse } from "./task.dto";
export class TaskApi extends Contract({
prefix: "/api/tasks",
routes: {
list: Route.get("/").scopes("task:read").responses({ 200: TaskResponse }),
create: Route.post("/").scopes("task:create").body(CreateTaskBody).responses({ 201: TaskResponse }),
},
}) {}
6. Run it
There's no entry file to write — pnpm dev serves the app. Register, log in, capture the token, then call
the guarded route with it.
# Register and log in -> capture the token from the JSON body
curl -s -X POST localhost:3000/auth/register \
-H 'content-type: application/json' \
-d '{"email":"alice@example.com","password":"correct-horse-battery"}'
TOKEN=$(curl -s -X POST localhost:3000/auth/login \
-H 'content-type: application/json' \
-d '{"email":"alice@example.com","password":"correct-horse-battery"}' | jq -r .accessToken)
# Call a protected route with the bearer token
curl -s localhost:3000/auth/me -H "authorization: Bearer $TOKEN"
# -> 200 with the caller's profile
curl -s localhost:3000/admin/stats -H "authorization: Bearer $TOKEN"
# -> 403: alice holds profile:read, not admin:stats:read
Pitfalls
The guard 401s every request because it runs before the auth middleware. GuardMiddleware reads the
principal the auth middleware stores; if the guard runs first the slot is empty, and every guarded route
comes back 401 Authentication is required even with a valid token. Middleware runs in list order, so put
the auth middleware before the guard: middlewares: [JWTAuthMiddleware, GuardMiddleware].
Assuming every framework class needs a wrapper. JWTAuthMiddleware and other shipped classes whose
constructor is plain DI tokens are bare providers — list them directly, as in step 4. useFactory is only
needed where a class reads a property of a dependency, calls a static factory, or takes a non-DI
constructor argument (AuthContext's CookieTokenSource) — the compiler can't express any of those from a
constructor parameter type alone.
Scopes are typed string[] until you narrow them. A scope is just a string, so a typo in
"admin:stats:read" won't be caught. Pin the set to your app's permission codes by declaration-merging the
global seam — declare global { interface RequestDefaults { scopes: MyPermissionCodes } } — and every
scope string is then checked at compile time.
See also
- Authentication — how JWT signing, verification, and the request-scoped
principal fit together, with the full
AuthContextand key-handling surface. - Permissions — declaring
scopeson contract routes and enforcing them withGuardMiddlewareversus an explicitisAllowedcheck. - Controllers — how a
middlewarechain nests broad to narrow, and why auth must run before the guard. - Build a CRUD API — apply this middleware and these scopes to a typed, Drizzle-persisted CRUD feature end-to-end.
- the ladder's L07 — Auth
— the runnable identity service this recipe is drawn from, with an end-to-end test proving the full
401/403/200scope ladder over the real compiler.
Build a CRUD API
A typed CRUD feature end-to-end — a zod-validated body, an inline-route Controller, a plain repository over Drizzle, boot-time table creation, and an in-process test.
Background Jobs
Move work off the request path with a typed IntegrationEvent producer, an IntegrationEventHandler consumer, an in-process MemoryIntegrationEventTransport default, and an optional transactional-outbox tier for at-least-once delivery.