Heximon Logo
Essentials

Permissions & scopes

Declare per-route scopes with Route.scopes, enforce them with GuardMiddleware, and run manual checks through AuthContext.isAllowed on inline routes.

Authentication tells you who is calling; permissions decide what they can do. Heximon's answer is scope-based: a signed token carries the caller's granted scopes inline, each route declares the scopes it requires, and a guard compares the two before your handler runs. Scopes are plain strings — there is no permission registry to install — so a common convention is domain:resource:action, like task:create or admin:stats:read.

You enforce them one of two ways: automatically with GuardMiddleware for routes declared on a contract, or manually with AuthContext.isAllowed(...) for inline routes. Both read the same principal the auth middleware stored, and both produce the same 401 (no principal) / 403 (under-scoped) distinction.

Declare scopes on a contract route

Route.scopes(...) records the required scopes directly on the route descriptor, alongside its body and response schemas. A route with no scopes() is open; a route with scopes requires a principal that grants every one of them.

task.api.ts
import { Contract, Route } from "@heximon/contract";
import { SchemaArray } from "@heximon/schema";
import { CreateTaskBody, TaskIdParams, TaskResponse } from "./task.dto";

class TaskListResponse extends SchemaArray(TaskResponse) {}

export class TaskApi extends Contract({
  prefix: "/api/tasks",
  routes: {
    list: Route.get("/").scopes("task:read").responses({ 200: TaskListResponse }),
    create: Route.post("/").scopes("task:create").body(CreateTaskBody).responses({ 201: TaskResponse }),
    complete: Route.post("/:id/complete")
      .scopes("task:complete")
      .pathParams(TaskIdParams)
      .responses({ 200: TaskResponse }),
  },
}) {}

Keeping the scope on the contract means the requirement travels with the route everywhere the contract goes — the server enforces it, OpenAPI documents it, and a client reads it from the same value. The dispatcher seeds that descriptor into the request Context at match time, which is exactly where the guard looks for it next.

Enforce with GuardMiddleware

GuardMiddleware reads the matched route's scopes from the request Context and compares them against the stored principal. Like JWTAuthMiddleware (see Authentication), it is a bare provider — its constructor is only DI tokens (Context + AuthContext), so the compiler recovers it from the package's shipped types and constructs it directly. There's no wrapper to write; list both shipped classes in http.middlewares, auth first, guard second:

auth.module.ts
import { GuardMiddleware, JWTAuthMiddleware } from "@heximon/auth";
import { Module } from "@heximon/runtime";
import { TasksController } from "./tasks.controller";

export class AuthModule extends Module({
  providers: [
    // ...AuthContext / JWTFactory / IdentityKeyPair providers — see Authentication
  ],
  http: {
    controllers: [TasksController],
    middlewares: [JWTAuthMiddleware, GuardMiddleware],
  },
  exports: [JWTAuthMiddleware, GuardMiddleware /* ...and the rest */],
}) {}

Then list the same chain on the contract-bound controller — auth first, guard second:

tasks.controller.ts
import { GuardMiddleware, JWTAuthMiddleware } from "@heximon/auth";
import { type Action, type Controller } from "@heximon/http";
import { TaskApi } from "./task.api";

export class TasksController
  implements Controller<{ contract: TaskApi; middlewares: [JWTAuthMiddleware, GuardMiddleware] }>
{
  // Runs only for a principal holding `task:read` — enforced before the handler.
  public async list(action: Action<TaskApi, "list">) {
    // ...
  }

  public async create(action: Action<TaskApi, "create">) {
    // ... — requires `task:create`
  }

  public async complete(action: Action<TaskApi, "complete">) {
    // ... — requires `task:complete`
  }
}

For each request the guard resolves to one of four outcomes: open route → continue; scopes declared but no principal → 401; principal lacking a scope → 403; all scopes granted → continue. Your handler never sees an under-scoped caller.

Order the chain auth-then-guard, or every guarded route returns 401.GuardMiddleware reads the principal that JWTAuthMiddleware stored earlier in the same chain — it does no token verification of its own. If the guard runs first, the principal isn't there yet, so a route with declared scopes resolves to "scopes declared but no principal" and returns 401 application/problem+json even for a perfectly valid token. The fix is the list order: middlewares: [JWTAuthMiddleware, GuardMiddleware], auth before guard.

Check scopes manually on inline routes

Inline routes — declared by the handler's Get<...> / Post<...> parameter type rather than a contract — carry no scopes, so the guard has nothing to read. That's deliberate: inline routes trade the contract's shared metadata for terseness, and authorization is one of the things you give up. Authenticate with the auth middleware, then enforce the scope yourself through AuthContext.

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

This produces exactly the 401 / 403 outcomes a guarded contract route would, because isAllowed reads the same stored principal the guard would — the only difference is that you write the two checks by hand instead of declaring them on a route.

Type your scope codes

By default a scope is any string. Narrow it to your app's exact permission codes by declaration-merging the global RequestDefaults seam, so a typo in a scopes(...) call or an isAllowed(...) argument is a compile error instead of a silently never-granted permission.

scopes.d.ts
declare global {
  interface RequestDefaults {
    scopes: ("task:read" | "task:create" | "task:complete" | "admin:stats:read")[];
  }
}

export {};

scopes must be typed as the array of your scope codes — ("a" | "b")[], not a bare union — because RequestScope is derived as RequestScopes[number], which extracts the element type. Without this seam, scopes fall back to string — fine for prototyping, but the typed array is worth the one file once your permission set settles, because it turns "this guard never passes" debugging into a red squiggle.

See also

  • Authentication — issue tokens carrying the right permissions claim and wire AuthContext, the principal these scopes are checked against.
  • Contracts — where Route.scopes(...) lives alongside the body and responses schemas on a shared route table.
  • Controllers — the two controller modes and how a middlewares: [...] chain runs before a handler.
  • Example L07 — auth — register, login, a request-scoped AuthContext, and an /admin/stats route that proves the manual 401 / 403 / 200 scope ladder.
Copyright © 2026