Custom Auth API
Bind effect-auth operations to an application-owned HTTP contract.
A custom auth API lets your application choose the routes and exposed features while effect-auth keeps the hard parts: authentication orchestration, public error mapping, operation security, continuations, and session-cookie commitment.
Choose the seam
Choose per endpoint. You can expose only password sign-in now and add account management later. Read HTTP Operations before composing the workflow directly from domain services.
| Concern | effect-auth operation | Your application |
|---|---|---|
| Authentication workflow and continuations | Owns | Configures dependencies and UI |
Standard AuthRateLimit policy | Executes once when included | Provides its implementation and rate-limit store |
| HTTP authentication capabilities | Uses one captured metadata snapshot | Provides explicit metadata and enabled/disabled capability tags |
| Domain-to-public error mapping and cookies | Owns | Declares a compatible endpoint contract |
| Decode failures and origin/CSRF protection | Does not travel with the function | Attaches and provides middleware |
| Tenant, role, ownership, or step-up policy | Does not decide | Wraps the operation with App Guards |
| Browser contract | Standard built-in contract only | Adds a typed extension for custom routes |
Prefer app-owned named guards
For a copied application endpoint, bind policy next to the handler and keep provider infrastructure in Effect Context:
import { Duration, Effect } from "effect";
import { BotProtection } from "@effect-auth/core/AbuseProtection";
import { AuthRateLimit } from "@effect-auth/core/AuthRateLimit";
import {
EmailGuards,
IdentityGuards,
PasswordGuards,
RequestSecurity,
} from "@effect-auth/core/HttpApi";
import {
PasswordLogin,
PasswordRegistration,
} from "@effect-auth/core/Password";
const guardSignIn = PasswordGuards.signIn.withPolicy({
rateLimit: AuthRateLimit.rules([
{
id: "app.password.sign_in.ip",
key: "ip",
limit: 8,
window: Duration.minutes(15),
},
]),
botProtection: BotProtection.verify({
action: "password-sign-in",
allowedHostnames: ["app.example.com"],
outageMode: "fail-closed",
}),
});
const guardResetStart = PasswordGuards.resetStart.withPolicy(
RequestSecurity.noop()
);
const signUp = ({ payload, request }) =>
Effect.gen(function* () {
const guarded = yield* PasswordGuards.signUp({ payload, request });
const registration = yield* PasswordRegistration;
return yield* registration.signUp(guarded.input);
});
const signIn = ({ payload, request }) =>
Effect.gen(function* () {
const guarded = yield* guardSignIn({ payload, request });
const password = yield* PasswordLogin;
return yield* password.signIn(guarded.input);
});PasswordGuards.signUp uses library defaults. guardSignIn replaces only sign-in policy, without matching a route or operation string. guardResetStart is an explicit endpoint-local opt-out; it skips request rate limiting and bot verification but does not disable schema validation or domain invariants. Provide AuthRateLimitStandardLive() and one HttpAuthenticationCapabilities assembly globally. Its request metadata and enabled/disabled provider choices are snapshotted; guards do not discover conflicting AuthHttpApiConfig, BotChallengeVerifier, trusted-device cookie, or login-risk services from ambient context. A configured Verify policy with a disabled bot-verifier capability fails safely.
The same endpoint-local model covers email verification, email OTP, combined email auth, magic links, and authenticated identity availability:
const guardOtpStart = EmailGuards.emailOtp.start.withPolicy({
botProtection: BotProtection.verify({
action: "email-otp-start",
allowedHostnames: ["app.example.com"],
outageMode: "fail-closed",
}),
});
const guardedOtp = yield * guardOtpStart({ payload, request });
const availability = yield * IdentityGuards.availability({ payload, request });Email verify guards expose a trusted-device token only when the required HttpAuthenticationCapabilities assembly explicitly enables its cookie service. Combined email start runs request security exactly once. IdentityGuards.availability validates the session before request security and derives the rate-limit subject from the validated session, never from a request-provided user id. Raw ambient verifier, cookie, and risk-enricher services do not alter these guards.
Bind a minimal password group
This complete binding reuses two exported endpoint contracts, so their payloads, success variants, and declared errors exactly match PasswordHttpOperations. Sign-in can return a successful continuation such as RequiresMfa; do not flatten it into an error. See the Error Model.
import { AuthRateLimitStandardLive } from "@effect-auth/core/AuthRateLimit";
import {
AuthOriginCheckMiddleware,
AuthOriginCheckMiddlewareLive,
AuthSchemaErrorMiddleware,
AuthSchemaErrorMiddlewareLive,
} from "@effect-auth/core/HttpApi";
import {
PasswordHttpOperations,
PasswordHttpOperationsLive,
passwordSignInEndpoint,
passwordSignUpEndpoint,
} from "@effect-auth/core/HttpApi/Password";
import {
HttpBotVerifierCapability,
HttpLoginRiskEnricherCapability,
HttpTrustedDeviceCookieCapability,
layerNoDeps as httpAuthenticationCapabilitiesLayerNoDeps,
} from "@effect-auth/core/HttpApi/HttpAuthenticationCapabilities";
import {
HttpLoginApprovalFinalizerCapability,
HttpLoginApprovalStatusCapability,
LoginNotificationReportCapability,
PasswordEmailVerificationCapability,
layerNoDeps as httpEndpointCapabilitiesLayerNoDeps,
} from "@effect-auth/core/HttpApi/HttpEndpointCapabilities";
import { Effect, Layer } from "effect";
import { HttpApi, HttpApiBuilder, HttpApiGroup } from "effect/unstable/httpapi";
class AppPasswordHttpApiGroup extends HttpApiGroup.make("password")
.add(passwordSignInEndpoint)
.add(passwordSignUpEndpoint)
.prefix("/account/password")
.middleware(AuthSchemaErrorMiddleware)
.middleware(AuthOriginCheckMiddleware) {}
export class AppAuthApi extends HttpApi.make("AppAuthApi").add(
AppPasswordHttpApiGroup
) {}
const AppPasswordHttpApiGroupLive = HttpApiBuilder.group(
AppAuthApi,
"password",
Effect.fn("app.auth.password")(function* (handlers) {
const password = yield* PasswordHttpOperations;
return handlers
.handle("signIn", password.signIn)
.handle("signUp", password.signUp);
})
);
const HttpAuthenticationCapabilitiesLive =
httpAuthenticationCapabilitiesLayerNoDeps({
requestMetadata: {
ipSource: { _tag: "CloudflareConnectingIp" },
},
botVerifier: HttpBotVerifierCapability.Disabled(),
trustedDeviceCookie: HttpTrustedDeviceCookieCapability.Disabled(),
loginRiskEnricher: HttpLoginRiskEnricherCapability.Disabled(),
});
const HttpEndpointCapabilitiesLayer = httpEndpointCapabilitiesLayerNoDeps({
passwordEmailVerification: PasswordEmailVerificationCapability.Disabled(),
loginNotificationReport: LoginNotificationReportCapability.Disabled(),
loginApprovalStatus: HttpLoginApprovalStatusCapability.Disabled(),
loginApprovalFinalizer: HttpLoginApprovalFinalizerCapability.Disabled(),
});
const PasswordOperationsLive = PasswordHttpOperationsLive.pipe(
Layer.provide(AuthRateLimitStandardLive()),
Layer.provide(HttpAuthenticationCapabilitiesLive),
Layer.provide(HttpEndpointCapabilitiesLayer),
Layer.provide(Layer.mergeAll(AppAuthServicesLive, AppRateLimitLive))
);
export const AppAuthApiLive = HttpApiBuilder.layer(AppAuthApi).pipe(
Layer.provide(AppPasswordHttpApiGroupLive),
Layer.provide(PasswordOperationsLive),
Layer.provide(AuthSchemaErrorMiddlewareLive),
Layer.provide(
AuthOriginCheckMiddlewareLive({
mode: "secure",
origins: ["https://app.example.com"],
})
)
);AppAuthServicesLive, AppRateLimitLive, HttpAuthenticationCapabilitiesLive, and HttpEndpointCapabilitiesLayer are application composition from your password/runtime setup; HTTP server serving is also intentionally omitted. Build both capability Layers with their focused layerNoDeps, selecting the trusted proxy source and every explicit capability tag. AuthRateLimitStandardLive() and those exact snapshots are captured when constructing the operation service. Do not call AuthRateLimit.require again in the handler. Add only distinct application policy around a method, for example password.change(request).pipe(Guard.require(requireAccountAdmin()), mapAuthGuardErrors), with the endpoint declaring those mapped errors.
AuthSchemaErrorMiddlewareLive turns decode failures into the documented bad_request shape. Origin middleware protects the cookie-capable boundary; configure allowed origins for your deployment rather than treating rate limiting as CSRF protection.
Keep the endpoint's declared errors aligned when adapting a request or wrapping an operation. Expected failures such as invalid credentials, policy denial, and rate limiting remain typed and safe for clients; unexpected storage, hashing, or session failures are sanitized by the operation. Map any new app-guard failure into a declared public error before returning it. Never expose repository causes merely because they are present in an Effect error channel.
Finally, changing /auth/password/* to /account/password/* means the standard browser protocol cannot discover these routes. Define an Effect HttpApi client extension with defineAuthHttpApiExtension, or implement a separate client, as described in Browser Client. Server reuse does not imply client compatibility.
For a new verifier and evidence semantics, use Custom authentication methods. For a concrete Alchemy v2 partner protocol and endpoint contract, see Accept a partner assertion on Cloudflare. Custom MFA and Step-up verifiers remain application-owned and are not registered by this path.