effect-auth

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. It is the middle path described in Architecture: more control than a preset, less security-critical code than calling primitives.

Choose the seam

Do the standard routes and client fit?
  yes -> mount CoreAuthHttpApiLive
  no  -> Is the authentication workflow still standard?
           yes -> bind *HttpOperations in your HttpApi
           no  -> compose primitives and own every skipped concern

Choose per endpoint. You can expose only password sign-in now and add account management later. Read HTTP Operations before dropping to primitives.

Concerneffect-auth operationYour application
Authentication workflow and continuationsOwnsConfigures dependencies and UI
Standard AuthRateLimit policyExecutes onceProvides its implementation and rate-limit store
Domain-to-public error mapping and cookiesOwnsDeclares a compatible endpoint contract
Decode failures and origin/CSRF protectionDoes not travel with the functionAttaches and provides middleware
Tenant, role, ownership, or step-up policyDoes not decideWraps the operation with App Guards
Browser contractStandard preset onlyAdds 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 provider infrastructure globally. A configured Verify policy without BotChallengeVerifier 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" }),
});

const guardedOtp = yield * guardOtpStart({ payload, request });
const availability = yield * IdentityGuards.availability({ payload, request });

Email verify guards expose the optional trusted-device cookie alongside their domain-ready input. 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.

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.

app-auth-api.ts
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 { 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 PasswordOperationsLive = PasswordHttpOperationsLive.pipe(
  Layer.provide(AuthRateLimitStandardLive()),
  Layer.provide(Layer.mergeAll(AppAuthServicesLive, AppRateLimitLive))
);

export const AppAuthApiLive = AppPasswordHttpApiGroupLive.pipe(
  Layer.provide(PasswordOperationsLive),
  Layer.provide(AuthSchemaErrorMiddlewareLive),
  Layer.provide(
    AuthOriginCheckMiddlewareLive({
      allowedOrigins: ["https://app.example.com"],
    })
  )
);

AppAuthServicesLive and AppRateLimitLive are application composition from your password/runtime setup; HTTP server serving is also intentionally omitted. AuthRateLimitStandardLive() is supplied when constructing the operation service and the operation invokes it once. 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 custom authentication methods, do not accept aal, amr, or raw evidence from an HTTP request. Create customEvidence only after server-side verification and register its exact policyId plus positive policyVersion with CustomEvidencePolicies; an unregistered version fails closed. The policy maps trusted properties to a local tier, canonical custom AMR, and optional independent-second-factor status. Raw evidence and policy properties are server-only. Standard HTTP schemas and generated client types expose derived aal, amr, authTime, and mfaVerifiedAt summaries, not authentication events.

On this page