App-owned Guards
Compose application policy around reusable authentication operations.
Authentication establishes who is calling and supplies a validated CurrentSession / CurrentActor. Authorization decides whether that actor may perform this action. Keep both explicit: possession of a valid session does not grant tenant membership, a role, or access to a resource.
effect-auth's Guard module does not invent an authorization language. A guard is an Effect; Guard.all runs guards in order, while Guard.require and Guard.requireAll run them before an operation. Your application defines tenant, role, and ownership checks using its own repositories and error types.
Choose the right check
| Question | Kind | Typical placement | Failure |
|---|---|---|---|
| Is there a valid caller session? | Authentication | HTTP middleware | 401 unauthenticated |
May this actor enter tenant T? | Boundary authorization | Around the operation | 403 forbidden |
| Does the actor have the required role? | Boundary authorization | Around the operation | 403 forbidden |
| Does this resource belong to the actor or tenant? | Boundary authorization and domain invariant | Guard, then rechecked in the write transaction | 403 or safe 404 |
| Is AAL2 recent enough? | Boundary step-up | Around the sensitive operation | 403 step_up_required |
| Is this state transition legal? | Domain invariant | Domain service / transaction | Typed domain error |
A boundary guard answers whether this request may attempt an operation. It is useful for early rejection, but it must not be the only protection for mutable facts. For example, checking document ownership before calling transferDocument leaves a time-of-check/time-of-use gap; the domain write must constrain the update by document and owner/tenant, or verify both in the same transaction. Conversely, a database ownership constraint does not authenticate the HTTP caller.
request
-> decode + origin/CSRF middleware
-> authenticate session (CurrentSession / CurrentActor)
-> app guards: tenant -> role/permission -> ownership -> recent step-up
-> secured HTTP Operation (its AuthRateLimit runs once)
-> domain service (enforces invariants transactionally)
-> map typed failures to the public HTTP contractBuild app-owned guards
The following names marked app-owned are pseudocode, not effect-auth exports. Each returns an Effect<void, AppAuthorizationError, ...> and may read CurrentActor, CurrentSession, route context, or an application repository.
import * as Guard from "@effect-auth/core/Guard";
import * as StepUp from "@effect-auth/core/StepUp";
import { Duration } from "effect";
const updateDocumentGuard = (documentId: string) =>
Guard.all(
requireTenantAccess("tenant-1"), // app-owned pseudocode
requireRole("editor"), // app-owned pseudocode
requireDocumentOwner(documentId), // app-owned pseudocode
StepUp.toGuard({
aal: "aal2",
maxAge: Duration.minutes(10),
})
);StepUp.toGuard is a real export. It reads CurrentSession and fails with StepUpRequired; it does not verify a factor. The client completes a step-up flow, then retries the protected action so every attempt re-authenticates, re-authorizes, and re-evaluates freshness.
Compose the guard around the smallest complete operation, not around only a preliminary lookup:
const updateDocument = (request: UpdateDocumentRequest) =>
documents.update(request).pipe(
Guard.require(updateDocumentGuard(request.documentId)),
mapAppGuardErrors // app-owned mapping to this API's declared errors
);Guard.requireAll(a, b) is equivalent when guards are assembled at the call site. Guards execute left-to-right and stop at the first failure, so put cheap identity and tenant checks before repository lookups and step-up checks. Do not rely on that order to hide resources; deliberately map unauthorized ownership to 404 when your disclosure policy requires it.
Wrap HTTP Operations, do not duplicate them
HTTP Operations retain authentication orchestration, typed HTTP semantics, cookie commitment, and their configured standard security. An application-owned endpoint can add policy around the real operation service:
import { PasswordHttpOperations } from "@effect-auth/core/HttpApi/Password";
import * as Guard from "@effect-auth/core/Guard";
import { Effect } from "effect";
const changePassword = Effect.gen(function* () {
const password = yield* PasswordHttpOperations;
return (request: PasswordChangeRequest) =>
password.change(request).pipe(
Guard.require(requireAccountAdmin()), // app-owned pseudocode
mapAppGuardErrors
);
});Provide AuthRateLimitStandardLive() when constructing the operation layer, but do not call the same AuthRateLimit policy again in the wrapper. The operation already executes its operation-specific security; duplicating it can consume rate-limit budget twice or repeat other effects. The wrapper should add only application policy such as tenant access, business permissions, resource scope, or stricter action-specific abuse controls. See Security Policies and Architecture.
Map failures at the boundary
Keep guard errors typed until the HTTP boundary. Map missing/invalid authentication to 401; authorization denial to a sanitized 403 (or deliberate 404); StepUpRequired to 403 step_up_required; rate limits to 429; and unexpected repository failures to 500. Log stable reason codes and identifiers, not cookies, credentials, hostile headers, or sensitive resource data. Preserve StepUpRequired.failures for trusted diagnostics rather than returning them wholesale.
If you call primitives such as Sessions, PasswordLogin, or storage services directly, the application owns request authentication, authorization, origin/CSRF controls, abuse policy, auditing, error mapping, and session/cookie consequences. Sessions validates, creates, rotates, revokes, and updates sessions; StepUp evaluates assurance/freshness; Guard only composes Effects; HTTP Operations add standard operation security and HTTP behavior. Prefer Operations when only routes or app guards differ. Drop to primitives only when the workflow itself differs. See Custom Auth API.