Heximon Logo
Essentials

Authentication

Verify callers with JWT, hash passwords through the PasswordHashingAlgorithm port, read the request-scoped AuthContext, and list JWTAuthMiddleware directly in your app.

Authentication answers one question on every request: who is the caller? Heximon verifies a signed JWT off the request, turns it into a request-scoped principal, and lets any handler read that principal by injecting AuthContext. You issue the token at login by checking a password against a constant-time hash — through the abstract PasswordHashingAlgorithm port, so the same controller code hashes with Scrypt on Node or Pbkdf2 on the edge — and signing the caller's id and scopes with JWTFactory. Every piece is a plain DI provider — no decorators, class identity is the only token.

Install the package

pnpm add @heximon/auth

The only third-party dependency is jose for the JWT crypto. There's no compiler plugin to register — every auth class is an ordinary DI provider, or a middleware the HTTP plugin discovers.

Generate a key pair at boot

JWTFactory signs tokens with the private key; JWTVerifier (which AuthContext uses) verifies them with the public key. Generate one ES256 key pair via JwkService and hold both halves on a single class, so the signer and verifier provably share it — an EC keygen is a couple of milliseconds versus an RSA keygen's tens to hundreds, so this stays off the critical path even running at boot.

src/auth/identity-key-pair.ts
import { Base64JWK, type GeneratedKeyPair, JwkService, JWTVerifier } from "@heximon/auth";
import { Platform } from "@heximon/runtime";

/** The app's signing key pair — the signer and verifier both read it, so they can never drift apart. */
export class IdentityKeyPair {
  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), 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);
  }

  /** Decode a Base64-encoded private JWK, deriving the public half by stripping its private members. */
  public static fromEncoded(encoded: string): IdentityKeyPair {
    const privateKey = Base64JWK.decode(encoded);

    return new IdentityKeyPair({ privateKey, publicKey: JWTVerifier.toPublicJWK(privateKey) });
  }

  /** Dev-only: keys rotate on every boot, so a restart (or a second instance) invalidates every session issued so far. */
  public static async generate(): Promise<IdentityKeyPair> {
    return new IdentityKeyPair(await new JwkService().generate(IdentityKeyPair.algorithm));
  }
}

resolve() is what the module's useFactory calls (next section): it reads a Base64-encoded private JWK from HEXIMON_JWK when set — so the key survives a restart or a scale-out — and only falls back to generate(), a fresh dev-only pair, when the variable is unset.

Wire the auth providers in a module

JWTFactory and AuthContext take constructor arguments that mix injectable services with non-injectable configuration (a key, a TokenSource) — bind those with useFactory providers, where the factory's parameter types become the dependency declaration. JWTAuthMiddleware and the password hasher need no such wiring:

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 { JwksController } from "./jwks.controller";
import { UserAccountStore } from "./user-account-store";

export class AuthModule extends Module({
  providers: [
    UserAccountStore,
    { provide: PasswordHashingAlgorithm, useClass: Scrypt },
    JWTAuthMiddleware,
    {
      provide: IdentityKeyPair,
      useFactory: (): Promise<IdentityKeyPair> => IdentityKeyPair.resolve(),
    },
    {
      provide: JWTFactory,
      useFactory: (keys: IdentityKeyPair): JWTFactory => new JWTFactory(keys.privateKey),
    },
    {
      provide: AuthContext,
      useFactory: (context: Context, verifier: JWTVerifier): AuthContext =>
        new AuthContext(context, verifier, new CookieTokenSource("accessToken")),
    },
  ],
  http: { controllers: [AuthController, JwksController] },
  exports: [
    AuthContext,
    JWTAuthMiddleware,
    JWTVerifier,
    JWTFactory,
    UserAccountStore,
    PasswordHashingAlgorithm,
  ],
}) {}

Three things worth noticing:

  • The password hasher is bound to a port. { provide: PasswordHashingAlgorithm, useClass: Scrypt } lets the controller inject the abstract algorithm rather than a concrete hasher — swap Scrypt for Pbkdf2 on an edge deploy and nothing else changes (more on this in the next section).
  • JWTAuthMiddleware needs no useFactory. Its constructor is only DI tokens — an AuthContext, an optional flag — so the compiler recovers it straight from @heximon/auth's shipped types; list it in providers like any app class.
  • There is no separate JWTVerifier provider. JWTFactory extends JWTVerifier — it verifies with the same key it signs with — so providing JWTFactory alone satisfies every JWTVerifier-typed injection and export. AuthContext's factory still declares its second parameter as JWTVerifier (the capability it actually needs); the compiler resolves it to the JWTFactory instance.

IdentityKeyPair's factory is async — Heximon awaits it once at boot — and AuthContext's configures a CookieTokenSource so the middleware reads the accessToken cookie the login handler sets (the next section covers the header-based alternative).

List the shipped middleware directly

JWTAuthMiddleware and GuardMiddleware are both bare providers — their constructors take only DI tokens, so the compiler recovers them from the package's shipped types and constructs them directly. There's no app-owned wrapper class to write; JWTAuthMiddleware is already sitting in the module above, unwrapped, and it reads the bearer token (or the configured cookie), verifies it, and stores the principal. A missing or empty token continues as anonymous — deliberately, so /register and /login stay reachable without one — while a present-but-invalid token is always a hard 401 (RFC 9457 application/problem+json).

When a surface needs non-default behavior — say every request on it must carry a token — don't wrap it, subclass it: a small subclass still gets its own DI identity, and the compiler reads the super(...) call to recover the shipped base's constructor.

src/auth/api-auth.middleware.ts
import { AuthContext, JWTAuthMiddleware } from "@heximon/auth";

export class ApiAuthMiddleware extends JWTAuthMiddleware {
  public constructor(authContext: AuthContext) {
    super(authContext, true); // requireToken: true — a missing token is a 401
  }
}

List ApiAuthMiddleware the same way as the shipped class — it's still a bare provider:

providers: [/* … */ ApiAuthMiddleware],

A middleware that needs configuration DI truly can't express — not a property of a dependency, not a super() argument, but a literal like a rate-limit threshold — can also be bound as { provide: SomeMiddleware, useFactory: ... } in providers, then referenced by class at every middleware site: the compiler constructs one instance and every reference resolves to it.

GuardMiddleware is a bare provider too (Context + AuthContext) — see Permissions & scopes for how it reads a route's declared scopes and enforces them.

Choosing where the token comes from

The module above passes a CookieTokenSource to AuthContext's useFactory, so JWTAuthMiddleware reads the token from the accessToken cookie the login handler sets, rather than the Authorization: Bearer header. The two sources are mutually exclusive — there is no implicit fallback chain. CookieTokenSource reads only the named cookie and ignores the Authorization header, and vice versa; pick one and configure it once in the useFactory for AuthContext.

HeaderTokenSource is the default, and this is its explicit form:

// These three are equivalent:
new AuthContext(context, verifier);
new AuthContext(context, verifier, new HeaderTokenSource("authorization"));
new AuthContext(context, verifier, "authorization"); // deprecated string form

Both HeaderTokenSource and CookieTokenSource extend the abstract TokenSource base class, which you can also extend to plug in custom extraction logic (e.g. a query-parameter token for OAuth callback flows).

Hash passwords and issue a token at login

Registration and login are where you mint identity. register hashes the incoming password through the abstract PasswordHashingAlgorithm port — bound to Scrypt by the module above — and grants a default scope; login verifies it in constant time, then signs a PermissionsJWTPayload with JWTFactory — the standard JWT claims plus an inline permissions[] array — into a JWT whose value is the encoded string; me reads back the principal the controller-level JWTAuthMiddleware already established. The port is asynchash() / verify() / simulate() return promises — because the edge-safe Pbkdf2 algorithm derives via WebCrypto, which is async-only.

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);

    if (!account) {
      return action.respond(401, { error: "The authenticated account no longer exists." });
    }

    return 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 };
  }
}
Always run simulate() on the no-account branch.verify() does real key-derivation work, so a wrong password takes measurably longer than a lookup that finds nothing — and that gap tells an attacker which emails are registered (timing-based enumeration). await this.passwordHashing.simulate() burns an equivalent derivation on the missing-account path, so both failures take the same time and the flat 401 reveals nothing.
Hashing on the edge.Scrypt imports node:crypto, so it only runs on a Node-class deploy. For an edge deploy (Cloudflare Workers, Vercel Edge, Deno Deploy), inject Pbkdf2 instead — PBKDF2-HMAC-SHA256 over WebCrypto only (600,000 iterations, the OWASP floor). Both implement the same async PasswordHashingAlgorithm port, so only the provider binding changes. To verify both formats from one hasher — e.g. while migrating an existing scrypt store to pbkdf2 — inject new PasswordHashing([new Pbkdf2(), new Scrypt()]): it hashes with the first algorithm and verifies by dispatching on the stored hash's tag, so you re-hash old passwords on the next successful login.

Read the principal with AuthContext

Once the middleware has verified the token, any handler reads the caller by injecting AuthContext — exactly what me did above: subject() returns undefined for an anonymous request, which the handler turns into a 401 before it ever touches the account store.

These are the methods you'll reach for off AuthContext:

MethodReturns
subject()The token sub claim, or undefined when anonymous
principal()The AuthPrincipal, or null when anonymous
grantedScopes()The caller's granted scope codes
isAllowed(requiredScopes)Whether the principal grants every required scope
verify(token)Verify a raw token into an AuthPrincipal
bearerTokenFrom(request)Delegates to the configured TokenSource (header or cookie)

Generate cryptographic ids

For opaque, non-guessable identifiers — session ids, one-time tokens, password-reset codes — reach for RandomId.fromEntropySize. It draws from the OS CSPRNG and encodes the bytes as lowercase, unpadded, URL-safe Base32:

import { RandomId } from "@heximon/auth";

const sessionId = RandomId.fromEntropySize(20); // 20 bytes of entropy, RFC 4648 Base32

Publish a JWKS

When other services verify your tokens, they need your public key — and only that. JwkService.getPublicKeySet() returns an RFC 7517 JWK Set of the public keys alone. Private material is never exposed: JwkService.load() and JWTVerifier.toPublicJWK() strip private members, and each token's header embeds only the public components — the split is structural, not something you have to remember.

JwksController — already listed alongside AuthController in the module above — serves it at the conventional /.well-known/jwks.json path:

src/auth/jwks.controller.ts
import { JwkService } from "@heximon/auth";
import type { Controller, Get } from "@heximon/http";
import { IdentityKeyPair } from "./identity-key-pair";

export class JwksController implements Controller<{ prefix: "/.well-known" }> {
  public constructor(private readonly keyPair: IdentityKeyPair) {}

  public async jwks(action: Get<"/jwks.json">): Promise<Response> {
    const jwkService = new JwkService();

    jwkService.load(this.keyPair.publicKey);

    return action.respond(200, jwkService.getPublicKeySet());
  }
}

See also

  • Permissions & scopes — declare per-route scopes on a contract and enforce them automatically with GuardMiddleware, or check AuthContext.isAllowed() inline.
  • Controllers — how Controller, the middlewares: [...] list, and HttpAction fit together, including the controller-level middleware that runs before every handler.
  • Example L07 — auth — a runnable register / login / me flow with the PasswordHashingAlgorithm port, a boot-generated ES256 key pair, a JWKS endpoint, and a scope-guarded admin route.
Copyright © 2026