Password
Add password sign-up, sign-in, reset, and credential management with application-owned HTTP APIs.
Password authentication supports sign-up, sign-in, reset, and credential management. The recommended HTTP Operations layer preserves the standard workflows while your application owns its public API.
HTTP Operations
PasswordHttpOperations exposes sign-in, sign-up, reset, set, and change as typed endpoint-shaped functions. The operations retain password-domain behavior, public result mapping, session-cookie commitment, and standard rate limits for public operations.
Configure the feature
Password configuration belongs to the domain layers consumed by PasswordHttpOperationsLive. Configure the same hasher and risk policy for registration, reset, set, and change:
import {
AuthKernelFromPrimitivesLayer,
AuthKernelPrimitivesLayer,
} from "@effect-auth/core/AuthKernel";
import { AuthRateLimitStandardLive } from "@effect-auth/core/AuthRateLimit";
import { HttpAuthenticationCapabilitiesLive } from "./http-authentication-capabilities.live.js";
import { HttpEndpointCapabilitiesLayer } from "./http-endpoint-capabilities.layer.js";
import { PasswordHttpOperationsLive } from "@effect-auth/core/HttpApi/Password";
import {
PasswordDefaultLive,
PasswordResetDefaultLive,
type PasswordResetUrlInput,
type Pbkdf2PasswordHasherOptions,
} from "@effect-auth/core/Password";
import { Layer, Redacted } from "effect";
import {
AppEmailAcceptancePolicyLive,
AppPasswordRiskPolicyLive,
} from "./password-policy.live.js";
import { AppAuthRuntimeLive, AppRateLimitLive } from "./auth-runtime.live.js";
export const passwordHasher = {
iterations: 210_000,
saltBytes: 16,
hashBytes: 32,
} satisfies Pbkdf2PasswordHasherOptions;
export const passwordResetUrl = ({
challengeId,
secret,
}: PasswordResetUrlInput): string => {
const url = new URL("/reset-password", "https://app.example.com");
url.searchParams.set("challengeId", challengeId);
url.searchParams.set("secret", Redacted.value(secret));
return url.toString();
};
const PasswordDomainLive = Layer.mergeAll(
PasswordDefaultLive(
passwordHasher,
AppPasswordRiskPolicyLive,
AppEmailAcceptancePolicyLive
),
PasswordResetDefaultLive(
{ makeUrl: passwordResetUrl, passwordHasher },
AppPasswordRiskPolicyLive
)
);
const AuthKernelLayer = AuthKernelFromPrimitivesLayer.pipe(
Layer.provideMerge(AuthKernelPrimitivesLayer)
);
export const AppPasswordServicesLive = PasswordDomainLive.pipe(
Layer.provideMerge(AuthKernelLayer),
Layer.provideMerge(AppAuthRuntimeLive)
);
export const AppPasswordHttpOperationsLive = PasswordHttpOperationsLive.pipe(
Layer.provide(AuthRateLimitStandardLive()),
Layer.provide(HttpAuthenticationCapabilitiesLive),
Layer.provide(HttpEndpointCapabilitiesLayer),
Layer.provide(Layer.mergeAll(AppPasswordServicesLive, AppRateLimitLive))
);AppAuthRuntimeLive supplies storage, crypto, session secrets, AuthMailer, and domain configuration. AppRateLimitLive supplies the runtime used by AuthRateLimitStandardLive().
PasswordDefaultLive takes PBKDF2 compatibility-hasher options, PasswordRiskPolicy, and EmailAcceptancePolicy as positional arguments. The email policy runs only for new email sign-ups. Pass the same password-risk policy to PasswordResetDefaultLive; omitting these policy layers selects no-op defaults.
For production Node 24.7+ or Bun, prefer the runtime-isolated PasswordArgon2idNode or PasswordArgon2idBun adapter with the configuration and concurrency Layers from PasswordArgon2id instead of PasswordDefaultLive. Both adapters verify existing core PBKDF2 hashes and return needsRehash=true; successful sign-in migrates the credential with the existing compare-and-swap update, so a concurrent password change is never overwritten.
PBKDF2 settings affect newly written fallback hashes and cannot be configured below the secure generation floors. Bounded legacy records with weaker iteration counts remain verifiable and request rehash, while collision-prone undersized derived keys are rejected. Keep this path explicit for constrained runtimes without a supported Argon2id API.
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.
Define the contract
Reuse the built-in endpoint schemas 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
) {}To expose a narrower payload such as { email, password }, define an application endpoint and map it to the payload.identity expected by the operation. Derive tenant scope and allowlisted metadata server-side.
Bind the operations
import { PasswordHttpOperations } from "@effect-auth/core/HttpApi/Password";
import { Effect, Layer } from "effect";
import { HttpApiBuilder } from "effect/unstable/httpapi";
import { AppAuthApi } from "./auth-api.js";
import { AppPasswordHttpOperationsLive } from "./password-services.live.js";
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(AppPasswordHttpOperationsLive));PasswordHttpOperationsLive requires explicit HttpAuthenticationCapabilities and HttpEndpointCapabilities assemblies. Their tagged choices enable or disable trusted-device cookies, bot verification, login-risk enrichment, and password-triggered email verification; raw ambient services are ignored.
Mount the application API
import {
AuthHttpApiConfigLive,
AuthOriginCheckMiddlewareLive,
AuthSchemaErrorMiddlewareLive,
} from "@effect-auth/core/HttpApi";
import { Layer } from "effect";
import { HttpServer } from "effect/unstable/http";
import { HttpApiBuilder } from "effect/unstable/httpapi";
import { AppAuthApi } from "./auth-api.js";
import { AppPasswordHttpApiGroupLive } from "./password-api-group.live.js";
export const AppAuthHttpApiLive = HttpApiBuilder.layer(AppAuthApi).pipe(
Layer.provide(AppPasswordHttpApiGroupLive),
Layer.provide(
AuthOriginCheckMiddlewareLive({
mode: "secure",
origins: ["https://app.example.com"],
})
),
Layer.provide(AuthSchemaErrorMiddlewareLive),
Layer.provide(
AuthHttpApiConfigLive({
originPolicy: {
mode: "secure",
origins: ["https://app.example.com"],
},
requestMetadata: {
ipSource: { _tag: "CloudflareConnectingIp" },
},
})
),
Layer.provide(HttpServer.layerServices)
);Protect set and change
The standard operations authenticate the session for set and change, but do not add a standard rate limit, recent step-up requirement, audit event, or session revocation policy. Add those controls at the application boundary.
A guard that needs CurrentSession cannot simply wrap password.change, because the operation reads the session internally after an outer wrapper would run. Authenticate before invoking the operation or build a custom handler with makePasswordChangeHandler({ guards }), which runs guards after session resolution. See App-owned Guards.
The library owns: password-domain orchestration, standard public-operation security, typed HTTP results, continuations, and cookie commitment.
Your application owns: endpoint selection, public schemas, middleware, application-specific boundary policy, and sensitive-action policy.
Built-in contract
| Route | Required request | Authentication and result |
|---|---|---|
POST /auth/password/sign-in | identity, password | Public; auth result |
POST /auth/password/sign-up | identity, password | Public; auth result |
POST /auth/password/reset/start | identity | Public; 204 No Content |
POST /auth/password/reset/verify | challengeId, secret, password | Reset challenge; 204 |
POST /auth/password/set | password | Session; 204 |
POST /auth/password/change | currentPassword, newPassword | Session; 204 |
Sign-in optionally accepts deviceFingerprint and botChallenge. Sign-up additionally accepts allowlisted metadata. Reset start optionally accepts locale, metadata, and botChallenge; reset verify optionally accepts botChallenge. Password set optionally accepts metadata. The domain PasswordReset.start primitive retains a trusted-server secret override, but the built-in public HTTP contract always generates reset secrets on the server.
set adds a password to an account without an active password credential. change verifies the current password and replaces an existing credential.
Handle authentication continuations
Password sign-in and sign-up can return a successful continuation instead of creating a session immediately:
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
Submit the identity. The client sends a global email identity to
reset/start.
Return a generic response. A valid email-shaped request receives 204
whether the account is eligible or not.
Issue the challenge. For an eligible account, PasswordReset creates a
short-lived, single-use challenge.
Deliver the URL. The application builds the reset URL and AuthMailer
sends it. URL or delivery failure consumes the new challenge.
Submit the new password. The client posts the challenge ID, secret, and
new password to reset/verify.
Replace the credential. Verify consumes the challenge, applies password policy, writes the credential, and then revokes the user's sessions.
Enumeration resistance
For a valid global email identity, 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." Invalid identity kinds or scopes are not account-enumeration cases and can fail separately.
Reset verification consumes first
The challenge is consumed before password policy, hashing, and storage complete. If the new password is rejected or a later operation fails, the user needs a new reset link. Credential replacement and session revocation are sequential operations, not a documented cross-store transaction.
Security defaults
AuthRateLimitStandardLive() applies privacy-safe fixed-window rules to the four 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 rate-limit set or change. Configure trusted proxy headers only when requests arrive through a controlled reverse proxy; otherwise derive client IP from the direct connection.
Password policy is application-owned
Core enforces a maximum of 1024 UTF-8 bytes for newly written passwords. It intentionally does not prescribe minimum length, character classes, breached-password checks, password history, disposable-email policy, or product-specific eligibility.
Apply the same PasswordRiskPolicy to 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. It never downloads data and the package does not bundle a domain list. Vendor a reviewed dataset in your application, record its source, revision, license, and digest, and update it through normal dependency review.
Use EmailAcceptancePolicy.make(reputation, { disposable: "deny", outage: { mode: "fail" } }) for direct construction, or provide the adapter layer to the policy's layerNoDeps. Pass the resulting policy Layer as the third argument to PasswordDefaultLive. The policy runs only for new normalized email sign-ups; existing password login is unaffected.
Hashing and reset defaults
| Setting | Default |
|---|---|
| PBKDF2-SHA-256 iterations | 210,000 |
| Salt length | 16 bytes |
| Derived hash length | 32 bytes |
| Maximum new password size | 1,024 bytes |
| Reset challenge lifetime | 15 minutes |
| Generated reset secret | 32 bytes |
| Generated credential/user/identity IDs | 16 bytes |
Benchmark hasher changes on production-like infrastructure. Treat hasher configuration as 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 identity/password combination |
policy_denied | 403 | An auth or application policy rejected the action |
step_up_required | 403 | Application policy requires fresher authentication |
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 maps missing users, missing or revoked credentials, and wrong passwords to invalid_credentials. Duplicate sign-up is intentionally visible as a conflict. The contracts for set and change can encode step-up and rate-limit errors, but the standard operations emit them only after application-owned controls are added.
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 recreate the password. - Every continuation state is handled by the client.
- Reset start remains generic for unknown and disabled accounts.
- Reset challenges are single-use even when later policy or storage work fails.
- Successful reset replaces the credential and 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 identity keys.