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.
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.
import { createAuthClient } from "@effect-auth/core/Client";
export const auth = createAuthClient({
requestInit: { credentials: "include" },
});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.
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
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.
| Capability | Service |
|---|---|
| Verify and complete the configured auth flow | PasswordLogin |
| Verify without creating a session | PasswordPrimaryFactor |
| Create a user and password credential | PasswordRegistration |
| Start and verify password reset | PasswordReset |
| Set or change a credential | PasswordManagement |
| Hash or verify a password hash | PasswordHasher |
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:
| Route | Authentication | Result |
|---|---|---|
POST /auth/password/sign-in | Public | Auth result or continuation |
POST /auth/password/sign-up | Public | Auth result or continuation |
POST /auth/password/reset/start | Public | 204 No Content |
POST /auth/password/reset/verify | Reset challenge | 204 No Content |
POST /auth/password/set | Session | 204 No Content |
POST /auth/password/change | Session | 204 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
- The client submits an email to
reset/start. 2. The server returns204whether the account is eligible or not. 3. For an eligible account,PasswordResetcreates a single-use challenge and sends the app-owned reset URL. 4. The client submits the challenge ID, secret, and new password toreset/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.
| Operation | Default limits |
|---|---|
| Sign in | 20 attempts per IP and 5 per email in 10 minutes |
| Sign up | 10 attempts per IP and 3 per email in 1 hour |
| Reset start | 10 attempts per IP and 3 per email in 10 minutes |
| Reset verify | 20 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
| Setting | Default |
|---|---|
| PBKDF2-SHA-256 iterations | 210,000 |
| Salt length | 16 bytes |
| Derived hash length | 32 bytes |
| Reset challenge lifetime | 15 minutes |
| Generated reset secret | 32 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
| Code | Status | Typical cause |
|---|---|---|
bad_request | 400 | Invalid payload or reset challenge |
unauthenticated | 401 | Missing or invalid session for set/change |
invalid_credentials | 401 | Wrong email/password combination |
policy_denied | 403 | An auth or application policy rejected the action |
step_up_required | 403 | Stronger or fresher authentication is required |
request_rejected | 403 | Origin validation rejected the request |
identity_already_registered | 409 | The normalized identity is already active in that namespace |
rate_limited | 429 | A configured security rule was exceeded |
internal_error | 500 | Storage, 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.