Heximon Logo
Recipes

Add Authentication

Protect routes end-to-end with IdentityKeyPair, JWTFactory, JWTVerifier, PasswordHashingAlgorithm, AuthContext, JWTAuthMiddleware, and scope guards — register, login, and a scope-guarded endpoint.

Protect your routes end-to-end. By the end you'll have:

  • POST /auth/register and POST /auth/login issuing 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 AuthContext any 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/stats returning 401 to anonymous callers, 403 to under-scoped ones, and 200 to 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.

src/auth/identity-key-pair.ts
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.

src/auth/user-account-store.ts
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.

src/auth/auth.controller.ts
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.

src/auth/auth.module.ts
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.

src/admin/admin.controller.ts
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 });
  }
}
src/admin/admin.module.ts
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.

src/tasks/task.api.ts
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.

terminal
# 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 AuthContext and key-handling surface.
  • Permissions — declaring scopes on contract routes and enforcing them with GuardMiddleware versus an explicit isAllowed check.
  • Controllers — how a middleware chain 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 / 200 scope ladder over the real compiler.
Copyright © 2026