effect-auth

Architecture

Understand effect-auth's integration layers, boundaries, security ownership, and composition model.

effect-auth exposes one authentication implementation at three integration levels. The levels are not separate products and do not contain competing authentication logic. They are progressively lower assembly points around the same domain services.

more convenience                                              more flexibility
less application ownership                            more application ownership

  Presets                 HTTP Operations                    Primitives
  ┌──────────────┐         ┌──────────────┐                  ┌──────────────┐
  │ routes       │         │ typed HTTP   │                  │ domain       │
  │ middleware   │ uses    │ operations   │ uses             │ services     │
  │ operations   ├────────▶│ + security   ├─────────────────▶│ + runtime    │
  │ client shape │         │ semantics    │                  │ capabilities │
  └──────────────┘         └──────────────┘                  └──────────────┘

Choose the highest level that gives your application the control it actually needs. Moving down adds ownership, not inherently better security or better architecture.

The three layers

LevelConvenienceFlexibilityApplication ownsLibrary still owns
PresetsHighestBuilt-in contractRuntime dependencies, configuration, enabled features, deploymentRoutes, endpoint binding, standard middleware, operation orchestration, typed errors, cookie commitment
HTTP OperationsHighApplication-owned routes and middlewarePublic API contract, endpoint selection, route guards, additional boundary policy, client contractAuthentication orchestration, standard operation security, HTTP success/error semantics, cookie commitment
PrimitivesLowestFull flow controlOrchestration, transport, authorization, security policy, error mapping, auditing, and session decisionsFocused domain capabilities and typed domain errors

Presets

Use CoreAuthHttpApiLive when the built-in /auth/* API is suitable. It assembles the standard HttpApi, group handlers, operation layers, schema-error middleware, and origin-check configuration. The matching createAuthClient targets this standard contract.

Presets are an assembly layer, not a second implementation of sign-in:

import {
  AuthHttpApiConfigLive,
  CoreAuthHttpApiLive,
} from "@effect-auth/core/HttpApi";
import { AuthRateLimitStandardLive } from "@effect-auth/core/AuthRateLimit";
import { Layer } from "effect";
import { HttpServer } from "effect/unstable/http";

export const AuthLive = CoreAuthHttpApiLive.pipe(
  Layer.provide(AuthRateLimitStandardLive()),
  Layer.provide(AppAuthServicesLive),
  Layer.provide(
    AuthHttpApiConfigLive({
      originCheck: { allowedOrigins: ["https://app.example.com"] },
    })
  ),
  Layer.provide(HttpServer.layerServices)
);

HTTP Operations

Use operation services such as PasswordHttpOperations when the application must own its HttpApi contract but wants to retain the library's complete HTTP-facing behavior. An operation accepts the endpoint request shape and returns the same typed success and error channels as the built-in endpoint handler.

import {
  PasswordHttpOperations,
  PasswordHttpOperationsLive,
} from "@effect-auth/core/HttpApi/Password";
import { Effect, Layer } from "effect";
import { HttpApiBuilder } from "effect/unstable/httpapi";

export const AppPasswordHttpApiGroupLive = HttpApiBuilder.group(
  AppAuthApi,
  "password",
  Effect.fn("app.auth.password")(function* (handlers) {
    const password = yield* PasswordHttpOperations;
    return handlers.handle("signIn", password.signIn);
  })
).pipe(Layer.provide(PasswordHttpOperationsLive));

The application may reuse built-in endpoint definitions or define its own schemas and adapt them before calling an operation. It must also apply the middleware appropriate to its public boundary. See Custom Auth API.

Primitives

Use services such as PasswordLogin, PasswordPrimaryFactor, Sessions, or storage services when authentication is part of an application-specific workflow that an HTTP operation does not model.

import { Email } from "@effect-auth/core/Identifiers";
import { PasswordLogin } from "@effect-auth/core/Password";
import { Effect, Redacted } from "effect";

const signIn = Effect.gen(function* () {
  const password = yield* PasswordLogin;
  return yield* password.signIn({
    email: Email("reader@example.com"),
    password: Redacted.make("correct horse battery staple"),
  });
});

This call has domain meaning, but no HTTP boundary meaning. It does not by itself perform an origin check, decode an untrusted request, map errors to an API, or guarantee that the caller applied rate limits. The application must provide those controls.

One implementation, different assembly

The password preset illustrates the composition:

CoreAuthHttpApiLive
  └─ CoreAuthPasswordGroupLive
       └─ handlers.handle("signIn", operations.signIn)
            └─ PasswordHttpOperationsLive
                 ├─ standard AuthRateLimit execution
                 ├─ HTTP/domain input and error mapping
                 ├─ session-cookie commitment
                 └─ PasswordLogin
                      └─ AuthFlow + password/storage/runtime services

CoreAuthPasswordGroupLive does not recreate sign-in. It obtains PasswordHttpOperations and binds operations.signIn to the endpoint. A custom API can bind that exact function elsewhere. A primitives integration calls PasswordLogin below the HTTP mapping and operation-security seam.

The practical comparison is therefore concise:

// Preset: the library binds all included groups.
const ServerLive = CoreAuthHttpApiLive;

// HTTP Operations: the application binds a library operation.
handlers.handle("signIn", passwordOperations.signIn);

// Primitives: the application designs and secures the flow.
yield * passwordLogin.signIn(domainInput);

For a complete worked example across all three levels, see Authentication: Password.

Choose per endpoint, not per application

The three levels are compositional choices, not an application-wide mode. One server can mount an unchanged feature preset, bind an HTTP operation to an application-owned endpoint, and implement another endpoint from primitives. Choose the highest useful level separately at each boundary.

For example, the same application can:

Endpoint areaIntegration levelReason
Passkey registration and sign-inPasskeyHttpApiLive presetThe built-in contract fits unchanged
Password sign-inPasswordHttpOperations.signInThe application wants its own route and request schema
Account recovery reviewRecovery and session primitivesThe transition is part of an application-specific workflow

The granularity is not identical at every level:

  • Presets mount contracts. CoreAuthHttpApiLive mounts its complete core contract; focused presets such as PasskeyHttpApiLive, TotpHttpApiLive, and StepUpHttpApiLive mount a feature contract. They are not runtime switches for replacing one endpoint inside an already mounted contract.
  • Operation services are assembled per feature. PasswordHttpOperationsLive, for example, constructs one service containing signIn, signUp, reset, set, and change operations. A custom group can bind any subset of those methods endpoint by endpoint, but the layer must satisfy the feature service's declared dependencies.
  • Primitives compose at call sites. Any custom handler can call PasswordLogin, Sessions, or another domain service directly, including alongside calls to HTTP operations. For each direct primitive path, the application owns the HTTP, security, error, audit, and session consequences skipped by that call.

HTTP operations retain their standard operation security, typed error/success semantics, and cookie commitment wherever they are bound. Their original group middleware does not travel with the function, so a custom endpoint still owns request decoding, origin/CSRF handling, caller guards, and any application-specific boundary policy.

Do not mount two handlers for the same effective method and path and expect one to override the other. To replace a built-in endpoint, omit or rebuild the preset group that owns that route and bind its operation or primitives in your custom contract. Alternatively, expose the custom behavior at a distinct path.

This makes gradual adoption possible: start with presets, replace only the endpoints whose public contracts need to diverge with HTTP Operations, and drop to Primitives only for workflows that require different domain orchestration.

Boundaries and ownership

Authentication crosses four distinct boundaries. Keeping them explicit prevents transport concerns from leaking into domain code and prevents domain assumptions from being mistaken for request security.

BoundaryResponsibilityTypical effect-auth types
DomainUsers, credentials, challenges, auth flow, and sessions as business capabilitiesPasswordLogin, PasswordManagement, AuthFlow, Sessions
RuntimeConcrete storage, cryptography, secrets, mail, clocks, and configurationLayer implementations provided by the application
HTTPDecode requests, authenticate callers, run boundary policy, map errors, and commit cookiesendpoint schemas, middleware, *HttpOperations, CoreAuthHttpApiLive
ClientCall a known HTTP contract and decode its public resultcreateAuthClient for the standard preset contract

The client boundary is not the domain boundary. If an application changes routes or public schemas at the HTTP Operations level, it owns the corresponding client contract. Likewise, a valid domain value does not prove that an HTTP caller is authorized to submit it.

Security executes exactly once

Standard operation security is part of the HTTP operation implementation. For example, PasswordHttpOperationsLive captures the configured AuthRateLimit service and its sign-in operation invokes the applicable policy before continuing into password authentication. Both the preset and a custom API call that same secured operation.

request → boundary middleware/guards → secured HTTP operation → domain service
                                      └─ AuthRateLimit.require(...) once

Consequences:

  • Providing AuthRateLimitStandardLive() to an operation layer configures its policy; it does not require callers to invoke that policy again.
  • Binding an operation into the preset or a custom group does not duplicate its standard security.
  • Do not wrap an HTTP operation in another copy of the same standard AuthRateLimit check. That can consume rate-limit budget twice or duplicate other policy effects.
  • Calling primitives bypasses the operation seam, so the application must arrange the required security once at its own boundary.

Not every endpoint has the same policy. Authentication, session, challenge, and credential-management operations have different requirements. Treat the operation as the owner of its documented standard policy rather than adding a blanket duplicate around every route. See Security Policies.

Boundary guards and business guards

These guard categories answer different questions and may both be necessary.

GuardQuestionExamplesPlacement
Boundary guardMay this request attempt the operation?authenticated session, recent step-up, tenant route access, application-specific rate limit, origin/CSRF policyMiddleware or around an HTTP operation
Business guardIs this state transition valid for the domain?credential belongs to the user, factor is active, challenge is unexpired, password is correct, session can be revokedInside domain services and operation orchestration

A boundary guard must not replace a business invariant. Conversely, a business invariant does not authenticate the incoming request. At the HTTP Operations level, compose application-specific guards around the operation while leaving its standard security intact:

return password
  .change(request)
  .pipe(
    Guard.requireAll(
      requireRecentStepUp(request.request),
      changePasswordRateLimit(request.request)
    ),
    mapAuthGuardErrors
  );

This adds application policy; it does not re-run the operation's standard policy. At the Primitives level, the same application boundary must additionally cover all controls that the skipped operation would have supplied.

Layer dependency composition

Effect layers make ownership visible in types. Build from runtime capabilities upward, provide lower layers to higher layers, and expose only the final services needed by the server.

application runtime
  storage + crypto + secrets + mail + configuration


AuthKernelLive + feature layers (for example PasswordDefaultLive())


domain services (PasswordLogin, Sessions, ...)


*HttpOperationsLive + AuthRateLimit


custom HttpApi groups or CoreAuthHttpApiLive
const AppAuthServicesLive = PasswordDefaultLive().pipe(
  Layer.provideMerge(AuthKernelLive),
  Layer.provideMerge(AppAuthRuntimeLive)
);

const AppOperationsLive = PasswordHttpOperationsLive.pipe(
  Layer.provide(AuthRateLimitStandardLive()),
  Layer.provide(AppAuthServicesLive)
);

const AppHttpLive = AppPasswordHttpApiGroupLive.pipe(
  Layer.provide(AppOperationsLive)
);

Layer.provide satisfies dependencies without automatically exposing the supplied layer's outputs. Layer.provideMerge satisfies dependencies and retains both outputs for later feature layers. Use the required-service type of the layer as the source of truth instead of relying on initialization order.

Optional feature services are discovered when an operation layer is constructed. Compose enabled features into the services supplied to that operation layer so the resulting operation has one stable implementation for both preset and custom bindings.

Decision guide

Choose Presets when:

  • The standard routes, schemas, middleware, and client contract fit.
  • You want secure defaults with the smallest integration surface.
  • Customization is primarily runtime configuration or enabling features.

Choose HTTP Operations when:

  • Your application owns route names, public payloads, middleware, or API grouping.
  • You need a subset of capabilities or application-specific boundary guards.
  • You still want standard orchestration, security execution, typed HTTP errors, and cookie behavior.

Choose Primitives when:

  • Authentication is one stage of a genuinely custom workflow.
  • The application must decide when to create a session or return a continuation.
  • Existing operations cannot represent the required domain behavior.
  • The team is prepared to own transport security, authorization, auditing, error mapping, and session consequences.

Do not move down merely to change a URL, add middleware, or add a boundary guard; HTTP Operations already provide those seams. Do not call a primitive from an endpoint merely to avoid adapting a schema; doing so also takes ownership of behavior that the operation would otherwise preserve.

On this page