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
| Level | Convenience | Flexibility | Application owns | Library still owns |
|---|---|---|---|---|
| Presets | Highest | Built-in contract | Runtime dependencies, configuration, enabled features, deployment | Routes, endpoint binding, standard middleware, operation orchestration, typed errors, cookie commitment |
| HTTP Operations | High | Application-owned routes and middleware | Public API contract, endpoint selection, route guards, additional boundary policy, client contract | Authentication orchestration, standard operation security, HTTP success/error semantics, cookie commitment |
| Primitives | Lowest | Full flow control | Orchestration, transport, authorization, security policy, error mapping, auditing, and session decisions | Focused 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 servicesCoreAuthPasswordGroupLive 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 area | Integration level | Reason |
|---|---|---|
| Passkey registration and sign-in | PasskeyHttpApiLive preset | The built-in contract fits unchanged |
| Password sign-in | PasswordHttpOperations.signIn | The application wants its own route and request schema |
| Account recovery review | Recovery and session primitives | The transition is part of an application-specific workflow |
The granularity is not identical at every level:
- Presets mount contracts.
CoreAuthHttpApiLivemounts its complete core contract; focused presets such asPasskeyHttpApiLive,TotpHttpApiLive, andStepUpHttpApiLivemount 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 containingsignIn,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.
| Boundary | Responsibility | Typical effect-auth types |
|---|---|---|
| Domain | Users, credentials, challenges, auth flow, and sessions as business capabilities | PasswordLogin, PasswordManagement, AuthFlow, Sessions |
| Runtime | Concrete storage, cryptography, secrets, mail, clocks, and configuration | Layer implementations provided by the application |
| HTTP | Decode requests, authenticate callers, run boundary policy, map errors, and commit cookies | endpoint schemas, middleware, *HttpOperations, CoreAuthHttpApiLive |
| Client | Call a known HTTP contract and decode its public result | createAuthClient 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(...) onceConsequences:
- 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
AuthRateLimitcheck. 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.
| Guard | Question | Examples | Placement |
|---|---|---|---|
| Boundary guard | May this request attempt the operation? | authenticated session, recent step-up, tenant route access, application-specific rate limit, origin/CSRF policy | Middleware or around an HTTP operation |
| Business guard | Is this state transition valid for the domain? | credential belongs to the user, factor is active, challenge is unexpired, password is correct, session can be revoked | Inside 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 CoreAuthHttpApiLiveconst 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.
Related guides
- Authentication: Password for a worked comparison of all three levels
- Custom Auth API for an application-owned contract
- Security Policies for standard and operation-specific policy
- Sessions for validation, rotation, revocation, and cookie behavior