Authorization Guide
Source of truth:
src/server/authz/,src/shared/constants/publicApiRoutes.ts,src/lib/api/requireManagementAuth.ts,src/shared/utils/apiAuth.tsLast updated: 2026-09-22 — scope namespaces point at MCP-SERVER.md
OmniRoute has a route-aware authorization pipeline that gates every API request. Classification is deterministic and fail-closed — anything that cannot be classified ends up as MANAGEMENT and demands a session or management-grade token. This page explains the model for engineers maintaining routes or designing new endpoints.
Source: diagrams/authz-pipeline.mmd
Two Auth Modes
Section titled “Two Auth Modes”1. API Key (Bearer)
Section titled “1. API Key (Bearer)”Used for the OpenAI/Anthropic/Gemini-compatible client APIs and a few management routes when the key has the manage scope.
Authorization: Bearer <api-key>Validated by isValidApiKey() / extractApiKey() in src/sse/services/auth.ts and re-exported through src/shared/utils/apiAuth.ts. The validator also accepts the OMNIROUTE_API_KEY / ROUTER_API_KEY env vars as persistent passthrough keys (issue #1350).
2. Dashboard Session (auth_token cookie)
Section titled “2. Dashboard Session (auth_token cookie)”For dashboard pages and admin operations.
Cookie: auth_token=<JWT signed with JWT_SECRET>A cookie is a session only when the JWT verifies and carries authenticated: true
(src/shared/utils/dashboardSessionToken.ts → verifyDashboardSessionToken). Every
consumer of the cookie (route guard, authz pipeline refresh, WebSocket handshake, live
server, /api/settings/require-login, /api/auth/status) goes through that helper.
Other JWTs signed with JWT_SECRET exist — the Cursor CLI passthrough mints
iss "omniroute" / aud "cursor-cli" tokens for key holders — and are never sessions
(#13298).
Verified by isDashboardSessionAuthenticated() in src/shared/utils/apiAuth.ts. The pipeline auto-refreshes the JWT when it has fewer than 7 days left in its 30-day lifetime.
Some management routes accept either mode: cookie OR Bearer <key> when the API key has the manage (or admin) scope. This is what enables the “configurable via API calls” workflow added in v3.8.
Optional OIDC login gate (#6973)
Section titled “Optional OIDC login gate (#6973)”The dashboard admin login also supports an opt-in OIDC (OpenID Connect) flow alongside the default password login — password login is never removed, only supplemented:
- Disabled unless
settings.oidcEnabled === trueandoidcIssuer/oidcClientId/oidcClientSecretare all configured (Settings → Auth).GET /api/auth/oidc/loginreturns400otherwise. GET /api/auth/oidc/logindiscovers theauthorization_endpointfrom the issuer’s/.well-known/openid-configuration(falls back to<issuer>/authorize), builds the redirect URI from the incoming request (x-forwarded-proto-aware), and redirects to the IdP with a randomstatestored in anhttpOnlyoidc_statecookie.GET /api/auth/oidc/callbackvalidatesstate, exchanges the authorization code, and verifies the ID token’s signature via the issuer’s JWKS (jose’screateRemoteJWKSet, cached per JWKS URI) withissuer/audiencechecks. An optionaloidcAllowedSubjectsallowlist matches the token’ssubclaim or itsemailclaim — the email claim is only honored whenemail_verified === true, so an unverified email at the IdP can never pass the gate.- On success it mints the exact same 30-day
auth_tokenJWT the password login issues (src/app/api/auth/login/route.ts), so the rest of the dashboard session pipeline (auto-refresh, cookie flags) is unchanged — OIDC only replaces how the cookie gets minted, not what it grants.
Route Classes
Section titled “Route Classes”src/server/authz/types.ts defines three classes; any route that cannot be classified deterministically falls back to MANAGEMENT.
| Class | Description | Auth required |
|---|---|---|
PUBLIC |
Explicitly safe routes — login, logout, status, init, health, onboarding bootstrap. | None |
CLIENT_API |
Model-serving endpoints — /api/v1/*, /api/v1beta/*, plus aliases /v1/*, /v1beta/*, /chat/completions, /responses, /models, /codex/*. |
Bearer key when the effective REQUIRE_API_KEY feature flag is enabled |
MANAGEMENT |
Dashboard pages, settings, providers, keys, admin and diagnostics endpoints. | Dashboard session OR Bearer with manage scope |
Pipeline
Section titled “Pipeline”Incoming request → src/proxy.ts → runAuthzPipeline() in src/server/authz/pipeline.ts 1. Strip trusted internal headers (x-omniroute-auth-*, x-omniroute-route-class) 2. Generate request id, classify route via classifyRoute() 3. If pathname == "/" → redirect /dashboard 4. If draining (graceful shutdown) and /api/* → 503 5. If non-GET /api/* → checkBodySize() guard 6. If OPTIONS → CORS preflight 204 7. If options.enforce == false → pass-through with route-class headers 8. Otherwise: POLICIES[routeClass].evaluate(ctx) - allow → stamp x-omniroute-auth-{kind,id,label,scopes} → NextResponse.next() - reject → JSON error w/ correlation_id (dashboard pages → 302 /login)Trusted internal headers (defined in src/server/authz/headers.ts) are stripped from incoming requests before classification — clients cannot pre-populate x-omniroute-auth-* to impersonate a subject.
Policy contracts
Section titled “Policy contracts”Each route class has a policy in src/server/authz/policies/:
publicPolicy(policies/public.ts) — always returnsallow({ kind: "anonymous", id: "anonymous" }).clientApiPolicy(policies/clientApi.ts) — extracts Bearer, validates viavalidateApiKey(). Falls through to anonymous only when the effectiveREQUIRE_API_KEYfeature flag is disabled. The effective flag is resolved throughisRequireApiKeyEnabled()(DB feature flag override > process.env.REQUIRE_API_KEY > default) so Dashboard Feature Flags and environment variables govern/api/v1/*,/api/v1beta/*, and aliases consistently; resolver failures fail closed. Allows dashboard-session requests on client API routes (including/api/v1/models, used by the dashboard model catalog).managementPolicy(policies/management.ts) — accepts dashboard session, internal model-sync requests (matched against/api/providers/[name]/(sync-models|models)), or skips entirely ifisAuthRequired()returns false. Returns 403 (AUTH_001) when a Bearer token is present but invalid, 401 otherwise. Also enforces the route-guard tiers (LOCAL_ONLY / ALWAYS_PROTECTED) before any auth branch — see Route Guard Tiers. LOCAL_ONLY paths inLOCAL_ONLY_MANAGE_SCOPE_BYPASS_PREFIXES(today:/api/mcp/) may be accessed from non-loopback when the Bearer key carries themanagescope; all other LOCAL_ONLY paths remain strict-loopback regardless of scope.
A successful policy returns AuthSubject with kind ∈ { client_api_key, dashboard_session, management_key, anonymous }. Downstream handlers can read it via assertAuth(request, "CLIENT_API") in src/server/authz/assertAuth.ts instead of re-running auth logic.
Public Routes List
Section titled “Public Routes List”src/shared/constants/publicApiRoutes.ts is the explicit allowlist:
The list is split by shape, and the split is load-bearing (GHSA-74g9-q8f6-793h): a prefix is
matched with startsWith(), so it also matches every adjacent path sharing its leading characters.
/api/usage/om-usage as a prefix marked /api/usage/om-usage<anything> PUBLIC, and Next resolves
that to /api/usage/[connectionId] — a handler with no auth of its own.
// Genuine subtrees. Every entry MUST end in "/" (asserted by a unit test).PUBLIC_API_ROUTE_PREFIXES = [ "/api/auth/oidc/", "/api/v1/", // treated as CLIENT_API in classify, not as "no-auth public" "/api/oauth/", "/api/codex/connect/", "/api/telegram/", "/api/cursor-cli/",];
// Single routes, matched EXACTLY (with or without a trailing slash).PUBLIC_API_ROUTES_EXACT = new Set([ "/api/auth/login", "/api/auth/logout", "/api/auth/status", "/api/init", "/api/sync/bundle", "/api/cli/connect", "/api/usage/om-usage", "/api/skills/collect/chaos",]);
// Read-only single routes that also take the CORS origin relaxation.PUBLIC_READONLY_CORS_API_ROUTES = [ "/api/health/ping", "/api/monitoring/health", "/api/settings/require-login",];
// Read-only single route WITHOUT the CORS relaxation.PUBLIC_READONLY_API_ROUTES_EXACT = new Set(["/api/health"]);
PUBLIC_READONLY_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);Read-only routes are public only for safe methods. Note: classifyRoute() excludes /api/v1/* and /api/v1beta/* from the PUBLIC fall-through — those are always CLIENT_API so the Bearer-key policy still applies.
Adding a New Route
Section titled “Adding a New Route”Pattern 1 — Public client API endpoint (Bearer-auth)
Section titled “Pattern 1 — Public client API endpoint (Bearer-auth)”Routes under /api/v1/ and /api/v1beta/ are classified CLIENT_API automatically. The middleware enforces the Bearer check; route handlers don’t need to redo it but can read the subject if useful.
import { NextRequest, NextResponse } from "next/server";import { assertAuth } from "@/server/authz/assertAuth";
export async function POST(req: NextRequest) { const subject = assertAuth(req, "CLIENT_API"); // subject.kind === "client_api_key" | "anonymous" | "dashboard_session" // ... handler logic}Pattern 2 — Management endpoint (session or Bearer + manage)
Section titled “Pattern 2 — Management endpoint (session or Bearer + manage)”Use requireManagementAuth() from src/lib/api/requireManagementAuth.ts:
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
export async function POST(request: Request) { const rejection = await requireManagementAuth(request); if (rejection) return rejection; // ... handler logic}requireManagementAuth() returns null on success or a JSON error Response:
- 401
AUTH_001“Authentication required” — no credentials at all - 403 — invalid Bearer or Bearer present but key lacks the
manage/adminscope
hasManageScope(scopes) returns true for "manage" or "admin".
Pattern 3 — Adding to the public allowlist
Section titled “Pattern 3 — Adding to the public allowlist”Pick the set by shape, not by convenience. One route goes in PUBLIC_API_ROUTES_EXACT (or PUBLIC_READONLY_CORS_API_ROUTES for GET-only); only a genuine subtree goes in PUBLIC_API_ROUTE_PREFIXES, and it must end in /. Putting a single route in the prefix list also publishes every adjacent path that shares its leading characters — including dynamic-segment siblings added later (GHSA-74g9-q8f6-793h). Update unit tests at tests/unit/public-api-routes.test.ts, tests/unit/authz/public-route-exact-match.test.ts and tests/unit/authz/classify.test.ts.
Scopes
Section titled “Scopes”Three namespaces. Each checker reads only its own strings. The side-by-side,
including why manage fails scopeMatches for read:compression and why a
read access token cannot PATCH /api/keys/{id}, is
Three scope namespaces.
API keys carry a scopes array (stored as JSON in api_keys.scopes, see src/lib/db/apiKeys.ts).
Management scope
Section titled “Management scope”manage/admin—hasManageScope. Bearer access to management API routes.mcp:connect,self:usage,self:account-quota, andpolicy:bypass-provider-quotaare additive exact-match scopes. They sit outsideMANAGEMENT_API_KEY_SCOPES.mcp:connectopens only the/api/mcp/non-loopback carve-out.
MCP tool scopes
Section titled “MCP tool scopes”Catalog and matching rules (identical string, or a granted scope ending in *):
MCP tool scopes.
MCP_SCOPE_LIST in src/shared/constants/mcpScopes.ts is the original typed
subset, not that full catalog. Enforcement runs in
open-sse/mcp-server/scopeEnforcement.ts after resolveCallerScopeContext()
resolves scopes from MCP auth info, request metadata, or OMNIROUTE_MCP_SCOPES.
It stays off unless OMNIROUTE_MCP_ENFORCE_SCOPES=true.
Access-token scopes
Section titled “Access-token scopes”read / write / admin on oma_live_… tokens, ranked by scopeSatisfies
(src/lib/accessTokens/scopes.ts). This rank applies to the access-token
credential only. See Management Authentication.
Auth Required Toggle
Section titled “Auth Required Toggle”isAuthRequired() in src/shared/utils/apiAuth.ts decides whether any auth is enforced for a request:
settings.requireLogin === false→ auth is globally disabled.- No password configured and no
INITIAL_PASSWORDenv var → bootstrap mode allows the onboarding wizard and loopback requests, but exposed network requests still need credentials. - Any DB error → fails closed (secure-by-default).
Client API key enforcement uses isRequireApiKeyEnabled() in src/shared/utils/featureFlags.ts, not a direct process.env.REQUIRE_API_KEY read. This matters for deployed instances: toggling REQUIRE_API_KEY in Dashboard → Feature Flags stores a DB override and immediately affects /v1/*, /v1beta/*, /models, /responses, /chat/completions, /codex/*, and other client-API auth checks that share this helper. If the feature flag store cannot be read, client API auth fails closed and requires a key.
Breaking Change — v3.8.0
Section titled “Breaking Change — v3.8.0”The /api/v1/agents/tasks/* and /api/resilience/model-cooldowns endpoints now require management auth (commit 588a0333). Clients previously sending a normal API key without the manage scope receive 403. Migration: either issue the key the manage scope in the API Keys dashboard, or use a logged-in dashboard session.
Behaviour Change — v3.8.2
Section titled “Behaviour Change — v3.8.2”/api/mcp/* (the remote MCP server) is still LOCAL_ONLY by default but now accepts non-loopback requests when the Authorization: Bearer <api-key> header carries the manage scope. The carve-out is gated explicitly per-path via LOCAL_ONLY_MANAGE_SCOPE_BYPASS_PREFIXES in src/server/authz/routeGuard.ts; the sibling LOCAL_ONLY prefix /api/cli-tools/runtime/* is intentionally NOT bypassable because it can spawn arbitrary subprocesses. Anonymous requests to /api/mcp/* from non-loopback continue to return 403 LOCAL_ONLY — the default for any new LOCAL_ONLY path remains strict-loopback. See Route Guard Tiers.
Testing
Section titled “Testing”- Unit tests:
tests/unit/authz/—classify.test.ts,pipeline.test.ts,client-api-policy.test.ts,management-policy.test.ts,public-policy.test.ts. - Public allowlist:
tests/unit/public-api-routes.test.ts. - Run focused:
node --import tsx/esm --test tests/unit/authz/classify.test.ts.
Debugging
Section titled “Debugging”The pipeline always stamps responses with:
x-request-id: <correlation id, echoed in error bodies>x-omniroute-route-class: PUBLIC | CLIENT_API | MANAGEMENTFor authenticated requests the upstream (handler-side) request headers also include:
x-omniroute-auth-kind: client_api_key | dashboard_session | management_key | anonymousx-omniroute-auth-id: key_<last-4> | "dashboard" | "anonymous"x-omniroute-auth-label: (optional)x-omniroute-auth-scopes: comma-separated listUse assertAuth(req, expectedClass) inside handlers — it throws AuthzAssertionError with code AUTHZ_NOT_INITIALIZED if the middleware was bypassed (helpful for catching configuration regressions in tests).
See Also
Section titled “See Also”- API_REFERENCE.md — auth marker per endpoint
- COMPLIANCE.md — audit log for auth events
- MCP-SERVER.md — three scope namespaces and MCP tool-scope catalog
- Source:
src/server/authz/,src/lib/api/requireManagementAuth.ts
HagiCode
HagiCode is an agentic coding workspace: structured workflows, multi-agent execution, and Hero Dungeon views turn ideas into shipped software.
Turn ideas into polished, usable software with a smarter, faster, and more enjoyable agentic coding workflow.

- SmartStructured workflows turn intent into an executable path from idea to shipped change.
- EfficientMulti-agent workflows keep research, implementation, and review moving in parallel.
- FunHero Dungeon interfaces make long coding sessions visual, collaborative, and rewarding.