effect-auth

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

QuestionKindTypical placementFailure
Is there a valid caller session?AuthenticationHTTP middleware401 unauthenticated
May this actor enter tenant T?Boundary authorizationAround the operation403 forbidden
Does the actor have the required role?Boundary authorizationAround the operation403 forbidden
Does this resource belong to the actor or tenant?Boundary authorization and domain invariantGuard, then rechecked in the write transaction403 or safe 404
Is AAL2 recent enough?Boundary step-upAround the sensitive operation403 step_up_required
Is this state transition legal?Domain invariantDomain service / transactionTyped 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 contract

Build 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.

On this page