effect-auth

Password

Add password sign-up, sign-in, reset, and credential management at the integration level your application needs.

Password authentication is available at three integration levels. All three use the same domain services and security model; they differ in how much HTTP and flow orchestration your application owns.

Choose the highest useful level

Start with Preset. Move to HTTP Operations when your application owns its HTTP contract, or to Primitives when it must own the authentication flow itself.

Preset

Mount the built-in API

CoreAuthHttpApiLive mounts the reference auth API, including all password routes, typed errors, cookie handling, origin checks, and standard security policy integration.

Use this level when the built-in /auth/password/* contract fits your application.

Server setup

Compose the password feature and reset feature with your auth runtime, then provide them to the complete HTTP preset.

auth.ts
import { AuthRateLimitStandardLive } from "@effect-auth/core/AuthRateLimit";
import { AuthKernelLive } from "@effect-auth/core/AuthKernel";
import {
  AuthHttpApiConfigLive,
  CoreAuthHttpApiLive,
} from "@effect-auth/core/HttpApi";
import {
  PasswordDefaultLive,
  PasswordResetDefaultLive,
  type PasswordResetUrlInput,
} from "@effect-auth/core/Password";
import { Layer, Redacted } from "effect";
import { HttpServer } from "effect/unstable/http";

const passwordResetUrl = ({
  challengeId,
  secret,
}: PasswordResetUrlInput) => {
  const url = new URL("/reset-password", env.AUTH_PUBLIC_URL);
  url.searchParams.set("challengeId", challengeId);
  url.searchParams.set("secret", Redacted.value(secret));
  return url.toString();
};

const PasswordLive = Layer.mergeAll(
  PasswordDefaultLive(),
  PasswordResetDefaultLive({ makeUrl: passwordResetUrl })
);

const AppAuthServicesLive = PasswordLive.pipe(
  Layer.provideMerge(AuthKernelLive),
  Layer.provideMerge(AppAuthRuntimeLive)
);

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

AppAuthRuntimeLive supplies storage, crypto, secrets, mail delivery, and domain configuration. AppRateLimitLive supplies the rate-limit runtime used by AuthRateLimitStandardLive().

Reset URLs contain a secret

Do not log the reset URL, query string, or Redacted.value(secret). Keep them out of traces, analytics, referrers, and error reports.

Browser client

The standard client matches the built-in routes and decodes their success and error schemas.

auth-client.ts
import { createAuthClient } from "@effect-auth/core/Client";

export const auth = createAuthClient({
  requestInit: { credentials: "include" },
});
password-actions.ts
const identity = {
  scope: { type: "global" as const },
  kind: "email",
  value: email,
};
const signUp = await auth.password.signUp({ identity, password });
const signIn = await auth.password.signIn({ identity, password });

await auth.password.reset.start({ email });
await auth.password.reset.verify({
  challengeId,
  secret,
  password: newPassword,
});

await auth.password.change({
  currentPassword,
  newPassword,
});

The preset owns: endpoint handlers, domain orchestration, error mapping, security-policy execution, session-cookie commitment, and optional feature integration.

Your application owns: runtime dependencies, password policy, reset URL and UI, continuation UI, and additional policy for sensitive credential changes.

HTTP Operations

Keep the orchestration, own the HTTP API

PasswordHttpOperations exposes the same implementations used by the preset as an Effect service. Bind them to an application-owned HttpApi when you need custom route groups, middleware, guards, or a selected subset of password capabilities.

Define the contract

Reuse the built-in endpoints directly when their payloads and responses fit your application.

auth-api.ts
import {
  AuthOriginCheckMiddleware,
  AuthSchemaErrorMiddleware,
} from "@effect-auth/core/HttpApi";
import {
  passwordChangeEndpoint,
  passwordResetStartEndpoint,
  passwordResetVerifyEndpoint,
  passwordSetEndpoint,
  passwordSignInEndpoint,
  passwordSignUpEndpoint,
} from "@effect-auth/core/HttpApi/Password";
import { HttpApi, HttpApiGroup } from "effect/unstable/httpapi";

class AppPasswordHttpApiGroup extends HttpApiGroup.make("password")
  .add(
    passwordSignInEndpoint,
    passwordSignUpEndpoint,
    passwordResetStartEndpoint,
    passwordResetVerifyEndpoint,
    passwordSetEndpoint,
    passwordChangeEndpoint
  )
  .prefix("/auth/password")
  .middleware(AuthSchemaErrorMiddleware)
  .middleware(AuthOriginCheckMiddleware) {}

export class AppAuthApi extends HttpApi.make("AppAuthApi")
  .add(AppPasswordHttpApiGroup) {}

Bind the operations

password-api.live.ts
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)
      .handle("signUp", password.signUp)
      .handle("resetStart", password.resetStart)
      .handle("resetVerify", password.resetVerify)
      .handle("set", password.set)
      .handle("change", password.change);
  })
).pipe(Layer.provide(PasswordHttpOperationsLive));

The operations preserve the exact endpoint success and error types. They also preserve session cookies, authentication continuations, standard security checks, and optional email-verification, trusted-device, and login-risk behavior.

Add application-owned guards

Boundary policy composes around an operation as a normal Effect transformation.

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

This is the recommended level when password change needs application-specific step-up or when your public sign-up payload must constrain identity scope and unrestricted metadata.

The library owns: password-domain orchestration, standard operation security, HTTP success/error semantics, and cookie commitment.

Your application owns: endpoint selection, public schemas, route names, middleware, and application-specific boundary policy.

Primitives

Own the authentication flow

Use domain services directly when password verification is one step in a larger application-owned flow or when HTTP operations do not represent the required behavior.

CapabilityService
Verify and complete the configured auth flowPasswordLogin
Verify without creating a sessionPasswordPrimaryFactor
Create a user and password credentialPasswordRegistration
Start and verify password resetPasswordReset
Set or change a credentialPasswordManagement
Hash or verify a password hashPasswordHasher

Standard domain sign-in

PasswordLogin.signIn verifies the password and passes the result to AuthFlow. The result may be authenticated or require another step.

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

const signIn = Effect.gen(function* () {
  const passwords = yield* PasswordLogin;

  return yield* passwords.signIn({
    identity: {
      scope: { type: "global" },
      kind: "email",
      value: "reader@example.com",
    },
    password: Redacted.make("correct horse battery staple"),
  });
});

Verify first, decide later

PasswordPrimaryFactor does not issue a session. Use it when your application decides whether to complete sign-in, require MFA, require approval, or deny access.

import { AuthFlow } from "@effect-auth/core/AuthFlow";
import { PasswordPrimaryFactor } from "@effect-auth/core/Password";

const verifyPassword = Effect.gen(function* () {
  const passwords = yield* PasswordPrimaryFactor;
  const authFlow = yield* AuthFlow;
  const primary = yield* passwords.verify({
    identity: {
      scope: { type: "global" },
      kind: "email",
      value: "reader@example.com",
    },
    password: Redacted.make("correct horse battery staple"),
  });

  if (primary._tag !== "Verified") {
    return primary;
  }

  if (yield* requiresMfa(primary.userId)) {
    return yield* authFlow.requireMfa({
      ...primary,
      factors: [{ type: "totp" }],
    });
  }

  return yield* authFlow.completePrimaryFactor(primary);
});

Feature layers

PasswordDefaultLive() provides hashing, primary-factor verification, login, registration, and credential management. Password reset is separate because URL construction and mail delivery are application-owned.

const PasswordDomainLive = Layer.mergeAll(
  PasswordDefaultLive(),
  PasswordResetDefaultLive({ makeUrl: passwordResetUrl })
);

Primitives do not imply boundary security

PasswordManagement.change does not authenticate an HTTP request, require step-up, rate-limit the action, audit it, or revoke sessions. Those controls become your responsibility at this level.

The library owns: individual domain capabilities and their typed domain errors.

Your application owns: orchestration, authorization, policy, transport, error mapping, auditing, and session decisions.

Built-in routes

The preset and the built-in endpoint contracts use these routes:

RouteAuthenticationResult
POST /auth/password/sign-inPublicAuth result or continuation
POST /auth/password/sign-upPublicAuth result or continuation
POST /auth/password/reset/startPublic204 No Content
POST /auth/password/reset/verifyReset challenge204 No Content
POST /auth/password/setSession204 No Content
POST /auth/password/changeSession204 No Content

set adds a password to an account that does not have an active password credential. change requires the current password and updates an existing credential.

Handle authentication continuations

Password sign-in and sign-up do not always create a session immediately. AuthFlow can return a successful continuation result instead of throwing an error.

const result = await auth.password.signIn({
  identity: { scope: { type: "global" }, kind: "email", value: email },
  password,
});

switch (result.type) {
  case "authenticated":
    // The session cookie has been committed.
    break;
  case "requires_mfa":
    // Continue with result.flowId and one of result.factors.
    break;
  case "requires_email_verification":
    // Continue the email verification flow.
    break;
  case "requires_login_approval":
    // Render the configured approval flow.
    break;
  case "requires_passkey_enrollment":
    // Enroll a passkey before completing authentication.
    break;
}

Treat these values as protocol states. Do not collapse them into a generic sign-in failure.

Password reset lifecycle

  1. The client submits an email to reset/start. 2. The server returns 204 whether the account is eligible or not. 3. For an eligible account, PasswordReset creates a single-use challenge and sends the app-owned reset URL. 4. The client submits the challenge ID, secret, and new password to reset/verify. 5. A successful reset consumes the challenge, updates or creates the password credential, and revokes every session for the user.

Enumeration resistance

Reset start deliberately returns the same public success for missing, disabled, and ineligible accounts. Keep browser copy generic, for example: “If an account exists, a reset link has been sent.”

Security defaults

AuthRateLimitStandardLive() applies privacy-safe fixed-window rules to the public password operations.

OperationDefault limits
Sign in20 attempts per IP and 5 per email in 10 minutes
Sign up10 attempts per IP and 3 per email in 1 hour
Reset start10 attempts per IP and 3 per email in 10 minutes
Reset verify20 attempts per IP in 10 minutes

The standard profile does not add password-policy checks or sensitive-action policy to set and change.

Protect set and change

Require a recent step-up or equivalent fresh authentication, add an application-owned rate limit, emit a sanitized audit event, and decide whether a successful change should revoke other sessions.

Password policy is application-owned

Core hashes and verifies passwords, but intentionally does not prescribe:

  • minimum length or character rules,
  • breached or common-password checks,
  • password history,
  • disposable-email policy,
  • product-specific signup eligibility.

Apply the same policy before sign-up, reset verify, password set, and password change. Keep policy outside PasswordHasher, and never attach a password or reset secret to logs, spans, analytics, errors, or audit metadata.

Pinned disposable-domain data

@effect-auth/core/DisposableEmailDomains is a local EmailReputation adapter. Pass makeDisposableEmailDomains or DisposableEmailDomainsLive an application-owned iterable; it never downloads data and the package does not bundle a domain list. For a CC0 list, vendor a reviewed release in your application and record its source URL, commit or release revision, license, and SHA-256 digest next to the snapshot. Update that snapshot through normal dependency review rather than at request time.

Use the resulting reputation service with makeEmailAcceptancePolicy(reputation, { disposable: "deny" }) and provide that policy to PasswordDefaultLive, or pass it directly to makePasswordRegistration. allowlist entries override the pinned data and application denylist. The policy runs only for new normalized email signups; existing password login is deliberately unaffected. makeIdentityManagementEmailAcceptance provides the same check as an opt-in decorator for identity add and replace operations.

Do not trust internal fields from a browser

The public identity must be explicit. Resolve tenant scope from trusted application context rather than accepting an unvalidated tenant ID from the browser, and allowlist metadata before calling the operation or primitive.

Hashing and reset defaults

SettingDefault
PBKDF2-SHA-256 iterations210,000
Salt length16 bytes
Derived hash length32 bytes
Reset challenge lifetime15 minutes
Generated reset secret32 bytes

Start from the library defaults and benchmark changes on production-like infrastructure. Treat hasher configuration as a versioned application policy; never copy reduced values from tests or development examples.

HTTP errors

CodeStatusTypical cause
bad_request400Invalid payload or reset challenge
unauthenticated401Missing or invalid session for set/change
invalid_credentials401Wrong email/password combination
policy_denied403An auth or application policy rejected the action
step_up_required403Stronger or fresher authentication is required
request_rejected403Origin validation rejected the request
identity_already_registered409The normalized identity is already active in that namespace
rate_limited429A configured security rule was exceeded
internal_error500Storage, crypto, delivery, or runtime failure

Sign-in deliberately maps missing users, missing or revoked password credentials, and wrong passwords to invalid_credentials. Duplicate sign-up is intentionally visible as a conflict; wrap that boundary if your product requires a generic response.

Testing checklist

  • Sign-up atomically stores the user, identity, and password credential and handles duplicate identities.
  • Sign-in returns the same public error for an unknown identity and a wrong password.
  • Username-only sign-up and sign-in work without an email identity.
  • Password credentials remain keyed by userId, so replacing a username does not move or recreate the password. Use the opt-in Identity Management API for post-registration identity changes; added email identities require verification before they become login-eligible.
  • Every continuation state is handled by the client.
  • Reset start remains generic for unknown and disabled accounts.
  • Reset secrets are single-use and expired challenges fail.
  • Successful reset revokes existing sessions.
  • Set/change require a session plus application-owned sensitive-action policy.
  • New-password policy runs on sign-up, reset, set, and change.
  • Schema and origin failures never echo passwords, reset secrets, or hostile headers.
  • Rate-limit tests cover IP and normalized email keys.

On this page