Authentication
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
npm install @heximon/auth
yarn add @heximon/auth
bun 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.
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:
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 — swapScryptforPbkdf2on an edge deploy and nothing else changes (more on this in the next section). JWTAuthMiddlewareneeds nouseFactory. Its constructor is only DI tokens — anAuthContext, an optional flag — so the compiler recovers it straight from@heximon/auth's shipped types; list it inproviderslike any app class.- There is no separate
JWTVerifierprovider.JWTFactory extends JWTVerifier— it verifies with the same key it signs with — so providingJWTFactoryalone satisfies everyJWTVerifier-typed injection and export.AuthContext's factory still declares its second parameter asJWTVerifier(the capability it actually needs); the compiler resolves it to theJWTFactoryinstance.
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.
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
async — hash() / verify() / simulate() return promises — because the edge-safe Pbkdf2 algorithm
derives via WebCrypto, which is async-only.
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 };
}
}
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.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:
| Method | Returns |
|---|---|
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:
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
scopeson a contract and enforce them automatically withGuardMiddleware, or checkAuthContext.isAllowed()inline. - Controllers — how
Controller, themiddlewares: [...]list, andHttpActionfit together, including the controller-level middleware that runs before every handler. - Example L07 — auth
— a runnable register / login / me flow with the
PasswordHashingAlgorithmport, a boot-generated ES256 key pair, a JWKS endpoint, and a scope-guarded admin route.
Migrations
Generate Drizzle migrations with stock drizzle-kit, then apply them with the injectable MigrationRunner, MigrationConfig, and a Task — one unified config drives both the CLI and the runtime.
Permissions & scopes
Declare per-route scopes with Route.scopes, enforce them with GuardMiddleware, and run manual checks through AuthContext.isAllowed on inline routes.