Heximon Logo
Recipes

Protect Admin Routes

Gate an admin-only surface with JWTAuthMiddleware for authentication and AuthContext.isAllowed for a scope-guarded 401/403/200 ladder on inline routes.

An /admin/stats route that only a caller holding admin:stats:read may reach — everyone else gets a 401 (no token) or 403 (wrong scopes). You'll reuse an existing auth module's exports rather than re-wiring JWT verification, and enforce the scope by hand since the route is declared inline. Every block is grounded in the runnable the ladder's L07 — Auth.

1. Start from a working login

This recipe assumes the Authentication flow already issues a token: login verifies the password in constant time, then signs the account's granted permissions straight into the JWT — so the scope check below never needs a database round-trip. The token rides both an HttpOnly accessToken cookie and the response body:

src/auth/auth.controller.ts
const expiresAt = new Date(Date.now() + AuthController.tokenLifetimeMilliseconds);
const token = await this.tokenFactory.create(
  { sub: account.id, permissions: [...account.permissions] },
  expiresAt,
);

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(),
});

(Source: examples/ladder/L07-auth/src/auth/auth.controller.ts)

2. Wire JWTAuthMiddleware once, export it

JWTAuthMiddleware's constructor is only DI tokens (an AuthContext), so it is a bare provider — list it directly, no app-local wrapper. Export it (and the AuthContext it populates) so another feature module can reuse the exact same auth wiring.

src/auth/auth.module.ts
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] },
  // Re-exported so feature modules (e.g. the AdminModule) can authenticate their own routes with the SAME
  // shipped middleware + read the SAME request-scoped principal, without re-providing any of it.
  exports: [
    AuthContext,
    JWTAuthMiddleware,
    JWTVerifier,
    JWTFactory,
    UserAccountStore,
    PasswordHashingAlgorithm,
  ],
}) {}

(Source: examples/ladder/L07-auth/src/auth/auth.module.ts)

IdentityKeyPair.resolve() reads a Base64-encoded private JWK from HEXIMON_JWK in production (surviving a restart or scale-out) and only falls back to a freshly generated ES256 pair in dev — see Authentication.

There is also 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 asks for a JWTVerifier (the capability it needs), and the compiler resolves it to the JWTFactory instance. JwksController (also listed above) publishes the public half at /.well-known/jwks.json — see Authentication's Publish a JWKS section.

3. Gate the admin route by scope

Inline routes — declared by the handler's Get<...> parameter type — carry no scopes, so there's nothing for an automatic guard to read. Check the scope by hand through AuthContext.isAllowed: it reads the same principal a guard would and produces the same 401 (no principal) / 403 (under-scoped) split.

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

(Source: examples/ladder/L07-auth/src/admin/admin.controller.ts)

A contract route gets this for free instead: declare Route.get("/stats").scopes("admin:stats:read") and let the shipped GuardMiddleware reject before your handler runs — see Permissions & scopes for that path.

4. Import the auth module, don't re-provide it

AdminModule imports AuthModule and lists only its own controller. The compiler wires AdminController's AuthContext from AuthModule's exports — no duplicate key pair, no second JWTAuthMiddleware instance.

src/admin/admin.module.ts
export class AdminModule extends Module({
  imports: [AuthModule],
  http: { controllers: [AdminController] },
}) {}

(Source: examples/ladder/L07-auth/src/admin/admin.module.ts)

5. Run it

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

curl -s localhost:3000/admin/stats -H "cookie: accessToken=$TOKEN"
# -> 403: alice only holds profile:read, not admin:stats:read

curl -s localhost:3000/admin/stats
# -> 401: no token at all

Pitfalls

Checking isAllowed without checking principal() first. An anonymous caller and an under-scoped caller are different failures (401 vs 403). Skip the principal() === null check and a request with no token at all silently falls through to the 403 branch, which is the wrong signal for a client deciding whether to prompt for login versus show a permission error.

Forgetting to export from the auth module. AdminModule only compiles because AuthModule lists AuthContext and JWTAuthMiddleware under exports. Miss the export and the importing module can't resolve either dependency — a build-time error, not a runtime surprise.

See also

  • Authentication — the full register/login flow this recipe starts from, including IdentityKeyPair generation and PasswordHashingAlgorithm.
  • Permissions & scopes — the contract-route form of this same scope check, enforced automatically by GuardMiddleware instead of by hand.
  • Add Authentication — the longer end-to-end recipe this one assumes, covering registration, password hashing, and token issuance from scratch.
  • Example L07 — auth — the runnable source, with an end-to-end test proving the full 401 / 403 / 200 scope ladder.
Copyright © 2026