App-owned Guards
Compose application authorization around reusable authentication operations.
Authentication proves a caller identity. A trusted boundary selects CurrentPrincipal. Application authorization decides whether that principal may act on a tenant or resource. Keep these steps explicit: a valid session does not grant membership, a role, or ownership.
@effect-auth/core/Guard is a small Effect combinator, not an authorization language. Use Policy.requirePermission with the maintained global-or-exact grant store or a custom Permissions backend; keep ownership, membership, and resource invariants app-owned.
| Check | Placement | Typical failure |
|---|---|---|
| Valid caller session | HTTP middleware | 401 unauthenticated |
| Tenant membership and permission | Around the operation | 403, or deliberate safe 404 |
| Recent AAL2 | Around the sensitive operation | 403 step_up_required |
| Resource still belongs to tenant | In the write transaction | Typed domain conflict |
| Legal state transition | Domain service / transaction | Typed domain error |
A guard rejects work early, but mutable facts must also constrain the write. A preflight ownership lookup alone creates a time-of-check/time-of-use gap.
Compose guards
Guard.all runs Effects left-to-right and stops on the first failure. Guard.require and Guard.requireAll run guards before an operation. The requireTenantAccess, requireRole, requireDocumentOwner, and mapAppGuardErrors names below are app-owned pseudocode.
import * as Guard from "@effect-auth/core/Guard";
import * as StepUp from "@effect-auth/core/StepUp";
import { Duration } from "effect";
const editDocumentGuard = (documentId: string) =>
Guard.all(
requireTenantAccess("acme"),
requireRole("editor"),
requireDocumentOwner(documentId),
StepUp.toGuard({
aal: "aal2",
maxAge: Duration.minutes(10),
})
);
const updateDocument = (request: UpdateDocumentRequest) =>
documents
.update(request)
.pipe(
Guard.require(editDocumentGuard(request.documentId)),
mapAppGuardErrors
);Place cheap identity and tenant checks before database lookups. Deliberately map unauthorized ownership to 404 when disclosure policy requires it; do not depend on evaluation order to hide resources.
StepUp.toGuard evaluates session assurance and freshness; it does not verify a factor. A static policy requires CurrentSession and can fail with StepUpRequired or CustomEvidencePolicyError. Adaptive policies additionally require StepUpCapabilities and may fail with StepUpDenied or StepUpCapabilityError. The browser completes the step-up flow, then retries the action so authentication, authorization, and freshness are re-evaluated.
Wrap HTTP Operations
HTTP Operations retain standard authentication orchestration, operation-specific abuse policy, public auth errors, and cookie effects. Add application policy around the exact endpoint-shaped operation instead of recreating it:
import * as Guard from "@effect-auth/core/Guard";
import {
PasswordHttpOperations,
type PasswordChangeOperation,
} from "@effect-auth/core/HttpApi/Password";
import { Effect } from "effect";
const makeChangePassword = Effect.gen(function* () {
const password = yield* PasswordHttpOperations;
const change: PasswordChangeOperation = (request) =>
password
.change(request)
.pipe(Guard.require(requireAccountAdmin()), mapAppGuardErrors);
return change;
});Use PasswordChangeOperation, not the browser client's payload type: an HTTP Operation receives the endpoint request shape, including decoded payload and server request context.
The outer guard runs before password.change. Middleware or the handler must therefore provide CurrentPrincipal and any required session context; a session resolved internally by the operation is not available to an earlier guard.
Provide AuthRateLimitStandardLive() when constructing the operation layer, but do not invoke the same AuthRateLimit again in the wrapper. Add only app policy or a distinct stricter action-specific limit.
Map errors once
mapAuthGuardErrors from @effect-auth/core/HttpApi maps standard rate-limit, step-up, policy-denial, and infrastructure guard failures to public auth errors. It cannot know your application errors; map those separately at the endpoint boundary.
| Internal outcome | Public boundary |
|---|---|
| Missing or invalid authentication | 401 unauthenticated |
| Authorization denial | Sanitized 403, or deliberate 404 |
StepUpRequired | 403 step_up_required |
| Rate limit exceeded | 429 |
| Policy backend or unexpected storage failure | Sanitized 500 internal_error |
Log stable reason codes and identifiers, never credentials, cookies, tokens, hostile headers, or complete resource records. Preserve detailed step-up and policy failures only in trusted diagnostics.
If you call domain services directly, the application also owns origin/CSRF controls, abuse policy, auditing, error projection, and session/cookie consequences. Prefer HTTP Operations when only routes or app authorization differ.