BFF Entry Points
A BFF is a driving host: it owns the browser-facing HTTP boundary and invokes application operations on behalf of one user experience. This skill owns the behavioral security model of that boundary — who may call each entry point, how that is enforced by construction, and how the application stays safe when a caller is not a browser. It follows the IETF's BCP-track guidance for browser-based apps: tokens stay server-side, the browser holds only a session cookie (draft-ietf-oauth-browser-based-apps §6.1 — BCP-track, in the RFC Editor queue at the time of writing; RFC 9700 / BCP 240 is the published OAuth security baseline).
Load bff-design for the pattern-level decisions this skill assumes are settled — whether to have a BFF at all, how many, aggregation and partial-failure design, and mediating user identity toward upstream services. Load structure-codebase for the physical endpoints/ tree, route catalog files, and dev-only routing; this skill defines what each endpoint leaf must declare and how registration enforces it. Load hexagonal-architecture for ports and adapters; this skill deepens its rule that authentication is adapter work and authorization is application policy. Load api-design for REST conventions, error body shape (RFC 9457), rate limiting, and versioning. Load secure-oauth-oidc for the OAuth/OIDC flows behind login; this skill owns the application session that results. Load codebase-design when shaping the registrar's contract — it is a deliberately deep module.
Read the relevant reference before implementing:
- Read
references/endpoint-protection.mdfor the endpoint contract shape, session cookie profile, Origin/Fetch Metadata/CSRF/content-type policy, and the derived entry catalog. - Read
references/hexagonal-auth-boundaries.mdforAuthenticatedPrincipal, in-application authorization, actor mapping, and row-level security as containment. - Read
references/realtime-entry-points.mdfor protected SSE streams and the protected WebSocket upgrade registrar. - Read
references/browser-session-coordination.mdfor the single browser-side authentication coordinator. - Read
references/enforcement-and-testing.mdfor the automated gates, positive controls, and hostile test matrix. - Read
references/hono-example.mdfor a concrete Hono registrar; the model is framework-neutral and maps to Fastify, Express, and Fetch-based routers.
Core Model: Explicit Access Classification
Every production entry point declares its access classification. There is no implicit default — an unclassified route is a build failure, not "protected by whatever middleware happens to run first" (OWASP API5:2023 mandates deny-by-default with explicit grants; CWE-306 names the missing check, not the broken check, as the vulnerability).
Minimum classification for browser-facing HTTP endpoints:
| Classification | Meaning | Registrar installs |
|---|---|---|
public |
Intentionally callable without an application session | Validation, abuse controls, explicitly unauthenticated docs |
protected-read |
Safe method for an authenticated user | Session resolution → 401 → in-application authorization |
protected-browser-mutation |
State change initiated by a browser | Origin/Fetch Metadata/content-type policy → session → CSRF → authorization |
// Canonical definition — references restate it only by pointer.
type EndpointAccess =
| { readonly kind: 'public'; readonly justification: string }
| { readonly kind: 'protected-read' }
| { readonly kind: 'protected-browser-mutation' }
| { readonly kind: 'protected-upgrade' }; // raw WebSocket upgrades — realtime reference
A project may extend the discriminated union — service credentials, signed webhooks, administrative operations — but every entry point still makes one explicit, exhaustive declaration, and each added kind defines its own verification chain in the registrar. Non-browser driving adapters (CLIs, job runners, queue consumers) are not exempt: each authenticates by its own mode and constructs its own caller principal (see the hexagonal reference's system-caller section).
Public is a claim about callers, not about trust. A public endpoint is still validated, rate-limited, monitored, and free of secrets — it is not network-private and not exempt from abuse controls. Health probes are the canonical example: public, but verdict-only (no dependency names, versions, or stack traces). The public set is an application decision; keep it small, snapshot it in a reviewed allowlist test, and treat any diff as a security review.
Source of Truth and the Derived Catalog
Endpoint contracts stay feature-local: route, validation schemas, access declaration, handler, and documentation live together at the endpoint leaf, so one review sees the whole story.
Do not build a second, hand-maintained central route-security map. It will drift from runtime registration, and the drift is invisible until an incident. Instead, derive a central entry catalog from the two things that are already true:
- the feature-local endpoint contracts; and
- the explicit production composition — which contracts were actually mounted, including raw WebSocket upgrades.
The registrar records every mount; the catalog is its output, never an input. Enforcement tests reconcile the catalog against the framework's route table and the generated OpenAPI document, so central visibility is automated instead of maintained.
The Prepared Registrar (a Deep Module)
Composition prepares the registrar once, supplying everything an endpoint must never choose for itself: session resolution, browser-request policy, safe error translation (RFC 9457 problem details without internals), and telemetry. An endpoint owner supplies only its contract and a thin handler.
// composition/ — the only place that knows the enforcement machinery
const endpoints = createEndpointRegistrar({
app, // framework instance (Hono, Fastify, Fetch router...)
sessions, // cookie → validated session → AuthenticatedPrincipal
browserPolicy, // exact-origin allowlist, Fetch Metadata, CSRF, content types
abuse, // rate limits and payload caps, keyed by endpoint contract
errors, // safe problem-details translation
telemetry,
});
// endpoints/api/orders/by-order-id/get.ts — contract + thin handler only
endpoints.register(getOrderContract, async ({ principal, params }) => {
const result = await orders.viewOrder({ principal, orderId: params.orderId });
return toHttpResponse(result);
});
The endpoint owner cannot choose, order, or omit authentication middleware — there are no optional flags. The handler's type is derived from the declaration (HandlerFor<Contract>, defined in the endpoint-protection reference): a public handler receives no principal; a protected handler receives an AuthenticatedPrincipal it could not have minted itself — non-constructibility is enforced by module boundary and an import-boundary gate, not convention. For public endpoints the registrar mounts the handler and emits explicitly unauthenticated API documentation (security: [], a deliberate greppable marker — never an omitted field). For protected endpoints it installs the verification chain by construction and emits matching OpenAPI security metadata, so runtime behavior and documentation cannot disagree.
This is a deep module in the codebase-design sense: a small stable contract (register) hiding ordering, header policy, error translation, and doc emission. Keep the registrar's core framework-neutral; only its inner adapter touches Hono/Fastify/Express APIs.
Two consequences follow, and both hold on every change, not only on greenfield work. The chain lives in the registrar and nowhere else: an endpoint module that checks Origin, a CSRF token or the content type itself has reopened the seam the registrar exists to close — put the missing check into the registrar's chain and let the endpoint receive it. Any route you change goes through the registrar in that change, a route already mounted straight on the app included: give it a declaration and move the mount. Moving a mount changes neither the app factory's signature nor its dependencies, so an instruction to keep those as they are is not a reason to leave the bypass in place. A state-changing kind is registered only with its whole chain. The change that adds one adds, in the registrar's branch for it and before the handler runs: an exact-Origin allowlist match, a Fetch Metadata check, a content-type check, session resolution, and a session-bound CSRF token check. All five, whatever you name the kind — a branch that resolves the session and checks the content type is a read chain that accepts a body, and registering a mutation on it is the omission this skill exists to prevent. Build in that same change whatever the chain needs and composition does not yet have: where no allowlist is configured, a service that serves one front end matches the request's own origin; where the session record carries no token field, mint one by HMAC over the session id (step 3 below). A missing collaborator is the work of this change, never permission to install fewer checks.
Request Ordering
For a protected read:
- resolve and validate the application session;
- return a stable 401 when authentication is absent or invalid;
- authorize the requested product resource or operation inside the application;
- only then read, subscribe, issue credentials, or perform other protected work.
For a protected browser mutation:
- reject invalid Origin, unsuitable Fetch Metadata, or unsupported content type — without revealing whether a session exists;
- resolve the application session;
- validate session-bound CSRF protection — not optional and not a follow-up: where the session record cannot carry a synchronizer token, mint one by HMAC over the session id with a server-side secret (signed double-submit, session-bound) and validate that, rather than registering the mutation with an Origin-and-content-type chain and leaving the token for later;
- perform any coarse operation or path-resource authorization available from the principal and validated params;
- parse and validate the bounded body;
- authorize every body-referenced object, field, target, and operation;
- only then perform effects.
Contract paths use OpenAPI {param} syntax everywhere (HTTP and upgrade
contracts alike); the registrar translates to framework syntax, and the
catalog stores the contract form. Put a primary target identity in the path
when that is the honest resource model, but do not contort batch, share, or
field-level operations merely to avoid body-dependent authorization. A
deferred body() thunk may preserve the coarse path check before parsing; all
bounded body data must still be validated and authorized before effects.
Stable failure semantics — same externally visible response per class, same code path (no cause-specific shortcut), never an oracle:
| Status | Meaning |
|---|---|
| 401 | Authentication is required or no longer valid |
| 403 | A deliberately disclosed request-policy or known-forbidden result |
| 404 | Inaccessible or cross-tenant resource whose existence must not be disclosed (RFC 9110 §15.5.4 explicitly sanctions this) |
| 415 | Unsupported content type |
API, SSE, and WebSocket entry points return protocol failures. They never redirect to an HTML login page — a redirected fetch yields unparseable 200 HTML, and a redirected EventSource fails opaquely on content type.
Hexagonal Responsibility Split
The BFF is a driving adapter. It owns cookies and request headers, session resolution, Origin/Fetch Metadata/CSRF/content-type policy, HTTP and upgrade protocol responses, translating an authenticated session into a provider-free principal, and invoking the application operation:
type AuthenticatedPrincipal = {
readonly userId: UserId; // branded domain types, not provider IDs
readonly tenantId: TenantId;
};
The BFF does not own product authorization. Every protected application operation independently authorizes the principal before performing protected effects — a precondition that holds whether the caller is HTTP, a CLI, a test harness, another BFF, or a future driving adapter (OWASP ASVS 5.0 §8.3.1: enforce at a trusted service layer). Inner code never depends on Hono, cookies, Keycloak, OAuth tokens, provider groups, HTTP status codes, or browser fields.
Authentication answers "who is calling?" — adapter work. Authorization answers "may this caller perform this product operation?" — application policy in product language (viewOrder refusing a foreign tenant), never a generic authorization-utilities bucket. Prove the rule where it lives: its test calls the operation directly with a foreign principal — no HTTP, no cookies, no registrar — and asserts the refusal result with no effect on the fakes. A test driven through the route proves the wiring, not the rule; an authorization rule you added or changed and covered only through HTTP is unfinished, because the next driving adapter does not go through that route. Map the principal to an attributable domain actor only after authorization succeeds. Database row-level security is final containment for missed checks, not the source of permission.
Why This Design
Compared designs for endpoint protection:
| Design | Verdict |
|---|---|
| 1. Global path-prefix middleware with public exceptions | Implicit and order-fragile: protection depends on registration order and path matching, upgrade requests bypass it, and the exception list is an unreviewed public allowlist. Middleware-only protection is bypassed in the wild (Next.js CVE-2025-29927; Clerk CVE-2026-41248; Traefik fail-open GHSA-4mr2-fg2p-w63c). |
| 2. Central hand-maintained route-security map | A second source of truth that drifts from runtime registration; drift is silent and fails open. |
| 3. Optional route-local middleware | Fail-open by design — the vulnerability is the absence of a check (CWE-306); every route owner re-decides ordering, and one omission is invisible in review. |
| 4. Mandatory feature-local declarations + prepared registrar + derived catalog | Preferred. Fail-closed by construction, reviewable at the leaf, centrally visible via derivation, and enforceable by tests. |
Tradeoffs of design 4, accepted knowingly: the registrar is upfront machinery that must itself be tested; the classification model must stay exhaustive as new authentication modes appear (extend the union, never add boolean flags); catalog derivation needs both contracts and production composition, so composition stays explicit; and a very small service may reasonably start with design 1 plus the enumerate-all-routes audit test — but adopt the registrar before the second authentication mode or the first realtime endpoint arrives.
Adopting on a brownfield system, ratchet instead of rewriting: (1) add the route-enumeration test first, with every currently unclassified route in an explicit exceptions list — the list may only shrink; (2) mount all new endpoints through the registrar from day one; (3) migrate existing routes leaf by leaf, highest-privilege first; (4) add direct provider-free refusal tests operation by operation as each route migrates — where the test cannot be written, authorization is middleware-only and that operation is your next fix; (5) tighten the gates to zero exceptions and only then trust the catalog.
Anti-Patterns
- A route mounted directly on the framework app in production code, bypassing the registrar — including one that predates it. Changing such a route at all, a security fix above all, means moving it onto the registrar with its own access declaration in that same change: a bypass you edited and left mounted is one you have now chosen, and fixing its logic in place leaves the next mistake unclassified.
requireAuth: false,skipCsrf, or any boolean that lets an endpoint owner weaken its own chain.- A hand-edited list of protected paths, or path-prefix matching as the authorization boundary.
- Authorization decided in HTTP middleware only — a non-HTTP caller then bypasses it entirely.
AuthenticatedPrincipalcarrying provider fields (token claims, Keycloak groups, cookie names) into the application.- Trusting a client-supplied actor, user ID, or tenant ID instead of the session-derived principal.
- 403 on cross-tenant resources — it confirms existence; use the no-oracle 404.
- Redirecting an API, SSE, or WebSocket entry point to a login page.
- Provider tokens or callback parameters entering browser storage (OWASP Session Management Cheat Sheet; the BFF exists so the browser holds only a cookie).
- A dev-only endpoint reachable from production composition (OWASP API9:2023).
Completion Check
- Does every mounted production entry point — including raw upgrades, and including any route this change touched that used to sit on the app — have an explicit access classification through the registrar?
- Can the public endpoint set be printed, and does a reviewed allowlist test pin it?
- Is there any way to mount a route without the registrar? If yes, does a gate fail?
- Do protected handlers receive a principal they cannot construct, typed by the declaration?
- Does a direct, provider-free test prove each protected operation refuses an unauthorized principal before any effect?
- Do runtime behavior, the derived catalog, and OpenAPI security metadata agree — verified by a test, not by discipline?
- Do 401, 403, 404, and 415 keep stable, non-oracle semantics across the whole surface?
- Does the browser have exactly one authentication coordinator, and does a realtime failure trigger one deduplicated current-user probe instead of an immediate sign-out?