npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@effect-auth/core

v0.1.0-alpha.20

Published

Composable Effect-first authentication primitives.

Readme

@effect-auth/core

Composable Effect-first authentication primitives.

The current alpha surface is primitive-first: service contracts, small helpers, make* factories, and reference Live layers that can be wired with application-owned policy and routes.

Public API Surface

The root entry is metadata-only:

import { packageName } from "@effect-auth/core";

Import every auth primitive, service, layer, identifier, and integration from its named public subpath:

import { UserId } from "@effect-auth/core/Identifiers";
import { PasswordLogin } from "@effect-auth/core/Password";
import { Sessions } from "@effect-auth/core/Sessions";
import { TotpSessionRotateStore } from "@effect-auth/core/TotpSessionRotationStorage";

Runtime-neutral primitive subpaths are stable alpha API. Examples include @effect-auth/core/Password, @effect-auth/core/Sessions, @effect-auth/core/TotpSessionRotationStorage, @effect-auth/core/RecoveryCodeSessionRotationStorage, @effect-auth/core/OAuth, @effect-auth/core/Jwt, @effect-auth/core/ApiKey, @effect-auth/core/Storage, and @effect-auth/core/RateLimiter.

Utility subpaths are stable alpha APIs:

@effect-auth/core/StorageMigrations

Integration subpaths are public but opt-in. They may expose runtime-specific or transport-specific types and require the app to install their adapter packages when used:

@effect-auth/core/Client
@effect-auth/core/HttpApi
@effect-auth/core/PasskeyBrowser

Storage and runtime adapter subpaths are public alpha adapters. Their names may still be normalized before beta:

@effect-auth/core/EffectQbSqliteStorage
@effect-auth/core/EffectQbSqliteOAuthStorage
@effect-auth/core/EffectQbPostgresOAuthStorage
@effect-auth/core/D1Sqlite
@effect-auth/core/D1SqliteAccountAuthStorage
@effect-auth/core/D1SqliteEmailVerificationCommitStore
@effect-auth/core/D1SqliteIdentityStore
@effect-auth/core/D1SqlitePasswordResetCommitStore
@effect-auth/core/D1SqlitePasswordSessionCommitStore
@effect-auth/core/D1SqliteRegistrationStore
@effect-auth/core/DrizzleEffectSqliteStorage
@effect-auth/core/DrizzleEffectSqliteUserStore
@effect-auth/core/DrizzleEffectSqliteCredentialStore
@effect-auth/core/DrizzleEffectSqliteIdentityStore
@effect-auth/core/DrizzleEffectSqliteVerificationStore
@effect-auth/core/DrizzleD1SqliteStorage
@effect-auth/core/DrizzleBunSqliteStorage
@effect-auth/core/DrizzleNodeSqliteStorage
@effect-auth/core/CloudflareEmail
@effect-auth/core/CloudflareRequestMetadata
@effect-auth/core/CloudflareRateLimitDurableObject
@effect-auth/core/AlchemyCloudflareEmail
@effect-auth/core/AlchemyCloudflareRateLimitDurableObject
@effect-auth/core/PasskeySimpleWebAuthn
@effect-auth/core/StorageSchemaGenerator
@effect-auth/core/DevelopmentSeed

Focused OAuth persistence can use EffectQbSqliteOAuthStorage or EffectQbPostgresOAuthStorage. These bundles provide OAuthAccountStore, OAuthIdentityBridgeStore, OAuthClientStore, OAuthConsentStore, OAuthAuthorizationCodeStore, and the atomic OAuthAuthorizationCodeCommit, atomic refresh rotation and reuse commits, device authorization and device-code token commits, client-secret storage, provider-mode token and refresh-family stores, and the provider-token vault, together with the canonical focused identity exports. Their catalogs contain only user/identity and OAuth tables, including the provider-token revocation outbox. MFA, passkey, audit, webhook, permissions, and other feature stores remain outside these bundles.

For verification-only compositions, import VerificationStore and its row/input types from @effect-auth/core/VerificationStorage. The legacy @effect-auth/core/Storage subpath re-exports the same runtime service. Use makeDrizzleEffectSqliteVerificationStore when an Effect-backed Drizzle SQLite database should access canonical auth_verification directly without loading Effect-QB; tableName accepts only an unqualified SQL identifier.

User-only and password-credential-only compositions can import their contracts from @effect-auth/core/UserStorage and @effect-auth/core/CredentialStorage. The legacy Storage subpath re-exports the same runtime service tokens. The direct makeDrizzleEffectSqliteUserStore and makeDrizzleEffectSqliteCredentialStore adapters access canonical auth_user and auth_credential tables without loading Effect-QB. Their optional tableName values are restricted to safe unqualified identifiers.

Identity-only compositions can import IdentityStore, IdentityConflictError, and the lookup/list/replacement types from @effect-auth/core/IdentityStorage. @effect-auth/core/Storage re-exports the same runtime token and error class. makeDrizzleEffectSqliteIdentityStore accesses canonical auth_user_identity directly, requires an Effect Drizzle SQLite database whose $client supports withTransaction, and does not load Effect-QB. It accepts a safe unqualified tableName and an optional row decode function.

Cloudflare D1 identity-only compositions can instead use makeD1SqliteIdentityStore from @effect-auth/core/D1SqliteIdentityStore. It talks to D1 directly, without Effect-QB or Drizzle, and uses D1 batches for atomic primary selection, replacement, revocation safety, and primary promotion. Its tableName and custom decode options follow the same rules as the direct Drizzle adapter. Neutral structural D1 protocol types and the small checked executor are available from @effect-auth/core/D1Sqlite.

Focused D1 registration compositions can use makeD1SqliteRegistrationStore from @effect-auth/core/D1SqliteRegistrationStore. User, first identity, and the optional password credential are committed in one D1 batch without loading a query builder. Custom user, identity, and credential table names must be safe unqualified SQL identifiers; optional decoders reuse the canonical SQLite row shapes.

Password session compositions can use makeD1SqlitePasswordSessionCommitStore from @effect-auth/core/D1SqlitePasswordSessionCommitStore. It directly binds session insert and rotation to the exact active password credential snapshot. The insert credential CAS and conditional session insert run in one D1 batch; custom credential and session table names must be safe unqualified SQL identifiers.

Password reset and email verification code compositions can use the focused makeD1SqlitePasswordResetCommitStore and makeD1SqliteEmailVerificationCommitStore adapters. Each directly executes the verification consume CAS and all dependent mutations in one D1 batch, without Effect-QB or Drizzle. Later statements are gated by the preceding changes() = 1; custom table names must be safe unqualified SQL identifiers.

For a complete basic password, email verification, and session storage graph, use makeD1SqliteAccountAuthStorage or D1SqliteAccountAuthStorageLive from @effect-auth/core/D1SqliteAccountAuthStorage. The entrypoint takes the D1 database for atomic commits plus a narrow Drizzle-compatible all(SQL) query executor for the four focused CRUD stores. It provides only UserStore, CredentialStore, VerificationStore, SessionStore, IdentityStore, RegistrationStore, PasswordSessionCommitStore, PasswordResetCommitStore, and EmailVerificationCommitStore; it does not load Effect-QB or unrelated feature storage. One table and decoder map is shared across all nine stores, so custom names cannot diverge between reads and atomic commits.

@effect-auth/core/Testing is test/dev-only API. Do not wire it into production applications.

SQL migrations are packaged as files for copy/read workflows, but the supported TypeScript APIs are authStorageMigrations from @effect-auth/core/StorageMigrations and postgresAuthStorageMigrations from @effect-auth/core/PostgresStorageMigrations. Raw SQL files are not importable package subpaths.

For fresh PostgreSQL or SQLite/D1 schema planning, @effect-auth/core/StorageSchemaGenerator exposes the same deterministic feature resolver used by the public API at https://effect-auth.itsbroly.com/api/generator/v1. Tools and AI agents should call /catalog, choose the database and adapter, then request migration-sql, development-seed, and, for Drizzle, drizzle-schema; they should not infer physical tables from store service types. @effect-auth/core/DevelopmentSeed provides the fixed 100-user, development-only runtime program used by generated seed launchers. Maintained runtime bridges are available for Drizzle PostgreSQL, D1, Bun SQLite, Node SQLite, and application-provided Effect SQLite databases.

Before beta, breaking changes are still allowed when they simplify this API surface. Keep the root metadata-only and add public APIs through explicit subpaths.

Provider-mode OAuth HTTP servers can import focused assemblies from @effect-auth/core/HttpApi/OAuthToken, @effect-auth/core/HttpApi/OAuthProviderAuthorization, and @effect-auth/core/HttpApi/OAuthDeviceAuthorization. Each entrypoint owns its canonical schemas, endpoints, API groups, operations, and live layer without loading the broad HttpApi assembly or unrelated authentication feature HTTP implementations. The broad Schemas, Endpoints, and Api modules only compose and re-export those same runtime objects.

The focused domain entrypoints are OAuthProtocol, OAuthClientConsent, OAuthTokenLifecycle, OAuthProviderAuthorization, OAuthProviderMode, OAuthDeviceFlow, and OAuthRelyingParty. @effect-auth/core/OAuth remains a compatibility entrypoint and re-exports the exact same runtime values; focused HTTP imports should use only the domain boundaries required by their flow. OAuth storage ports follow the same ownership: account/identity/provider vault belong to OAuthRelyingParty, client/consent/client-secret to OAuthClientConsent, authorization-code and refresh commits to OAuthTokenLifecycle, provider-mode token families to OAuthProviderMode, and device stores remain in OAuthDeviceAuthorization. The focused Effect-QB OAuth adapters depend only on those owners and do not load the broad OAuth module.

Cloudflare Request Metadata

@effect-auth/core/CloudflareRequestMetadata is an opt-in Worker-boundary adapter for login-risk composition. It uses structural request types and reads geographic facts from Cloudflare's request.cf. Client IP is omitted unless trustCloudflareConnectingIp is explicitly enabled at a verified Cloudflare boundary. It does not require @cloudflare/workers-types, nodejs_compat, or an IP database.

Call readCloudflareRequestMetadata(request) in Worker-owned code, then pass its coarse request, locationKey, and safe metadata fields into custom risk composition. Missing or malformed preview metadata produces unknown fields rather than an error. Never construct the input from browser JSON or forwarded geographic headers. Persist only the returned durable projection; the full result may contain transient IP and city values.

HTTP Client

@effect-auth/core/Client exposes one browser-friendly client for the standard user-facing auth groups. Applications still choose which groups to mount and which policies to apply on the server. The client accepts plain JSON input types, includes cookies by default, and decodes successful/error responses through Effect Schema.

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

const authClient = createAuthClient();

const result = await authClient.password.signIn({
  email: "[email protected]",
  password: "correct horse battery staple",
});

const session = await authClient.session.currentOrUndefined();

if (authClient.passkey.isSupported()) {
  await authClient.passkey.signIn();
}

createAuthClient() also includes combined email auth, passkeys, TOTP, recovery codes, login MFA, and step-up. Its passkey methods complete the standard start/browser/finish sequence. The focused createPasskeyClient, createTotpClient, createMfaClient, and other factories remain available for custom paths and low-level protocol control.

Authentication continuations such as requires_mfa, requires_email_verification, and requires_login_approval are successful protocol results, not exceptions. Handle the returned discriminated union in the UI.

Use @effect-auth/core/HttpApi when you are wiring the server or need the raw HttpApi contract. Use @effect-auth/core/Client in application UI code.

Relying-party OAuth HTTP wiring has a focused server entrypoint at @effect-auth/core/HttpApi/OAuth. It contains authorization start and callback shaping, account unlinking, the canonical schemas and endpoints, operation services, group binding, and the standalone OAuthHttpApiLive preset without loading OAuth token, provider-authorization, or device-authorization HTTP implementations. The flow-cookie service remains an explicit dependency and is also available from @effect-auth/core/HttpApi/OAuthFlowCookie; the standalone preset provides its default validated layer.

import { OAuthHttpApiLive } from "@effect-auth/core/HttpApi/OAuth";
import { OAuthFlowCookie } from "@effect-auth/core/HttpApi/OAuthFlowCookie";

export const OAuthRoutesLive = OAuthHttpApiLive;
export type OAuthFlowCookieDependency = OAuthFlowCookie;

HTTP Operations

Applications that own their routes can use the per-feature operation services instead of mounting the built-in API groups. Every built-in HTTP API group has a corresponding portable, typed operations service and Live layer. Each layer acquires its feature dependencies once and exposes ready endpoint operations with the same responses, cookies, errors, optional-service behavior, extension hooks, configuration, and security checks as the built-in handlers.

import { Effect } from "effect";
import {
  PasswordHttpOperations,
  PasswordHttpOperationsLive,
  type PasswordHttpOperationsService,
} from "@effect-auth/core/HttpApi/Password";

const signIn = (
  input: Parameters<PasswordHttpOperationsService["signIn"]>[0]
) => PasswordHttpOperations.use((operations) => operations.signIn(input));

// Provide PasswordLogin, PasswordRegistration, PasswordReset,
// PasswordManagement, Sessions, SessionCookie, AuthHttp, AuthRateLimit,
// IdentityKindRegistry, HttpAuthenticationCapabilities, and
// HttpEndpointCapabilities.
const PasswordRoutesLive = PasswordHttpOperationsLive;

@effect-auth/core/HttpApi/Password also exports the standalone PasswordHttpApiLive preset and the password endpoint contracts without loading the full server API assembly. Password, email, magic-link, and session focused entrypoints use feature-local canonical schema modules, so they do not load the passkey credential payload runtime or the optional SimpleWebAuthn adapter. The broad @effect-auth/core/HttpApi entry remains the aggregate boundary for CoreAuthHttpApiLive and cross-feature composition and re-exports the same schema objects for compatibility.

Feature operation services can also be replaced directly in tests with FeatureHttpOperations.of(...). Secured feature layers always require AuthRateLimit; they never install a no-op implementation.

Client Composition

The standard client is a starting point rather than an all-or-nothing abstraction. Its protocol option is a deep patch:

  • omitted properties keep the standard implementation,
  • functions and objects replace or extend it,
  • null removes an operation or group,
  • extensions adds app-owned auth operations under a collision-free namespace.
const auth = createAuthClient({
  protocol: {
    passkey: null,
    password: {
      signUp: customSignUp,
    },
    extensions: {
      organization: {
        select: selectActiveOrganization,
      },
    },
  },
});

await auth.password.signUp(customInput);
await auth.extensions.organization.select({ organizationId: "org-1" });

Literal null values also remove the corresponding property from the inferred client type.

Use defineAuthHttpApiExtension for an app-owned auth HttpApi contract that should share the standard client's base URL, Fetch configuration, cookies, cancellation, runtime, and disposal:

const appAuthExtension = defineAuthHttpApiExtension(
  AppAuthExtensionApi,
  ({ run }) => ({
    discoverSso: (email: string, options?: AuthClientRequestOptions) =>
      run(
        (client) => client.appAuth.discoverSso({ payload: { email } }),
        options
      ),
  })
);

const auth = createAuthClient({
  protocol: { extensions: appAuthExtension },
});

await auth.extensions.discoverSso("[email protected]");

Extension contracts should contain browser-safe endpoint/schema declarations, use unique group identifiers, and require no client-side middleware services. Product APIs that merely require an auth cookie should remain separate from the auth client.

Policy

Policy is an Effect used for authorization. Success means access is allowed. AuthorizationError means access is denied. Other failures stay operational errors.

import { Effect } from "effect";
import * as Policy from "@effect-auth/core/Policy";

const canEditProject = Policy.deny("missing-permission");

const updateProject = Effect.succeed({ updated: true }).pipe(
  Policy.require(canEditProject)
);

const decision = Policy.check(canEditProject);
const detailed = Policy.checkDetailed(canEditProject);

Use Policy.check when callers should branch on allowed/denied and let operational errors fail. Use Policy.checkDetailed when callers need a value for allowed, denied, or failed.

Permission policies read a trusted CurrentPrincipal, not session-specific CurrentActor. Permissions checks are subject-first, so the same policy seam can authorize a user, service account, API key, or another app-defined subject:

import { Effect } from "effect";
import {
  CurrentPrincipal,
  PermissionSubject,
} from "@effect-auth/core/Permission";

const principal = PermissionSubject.user(validated.actor.userId);

const authorized = operation.pipe(
  Effect.provideService(CurrentPrincipal, CurrentPrincipal.of(principal))
);

CurrentActor remains the authenticated session context containing userId and sessionId. Credential adapters must choose the authorization principal explicitly at a trusted boundary; never accept it from request input. Machine credential scopes and durable permissions are cumulative restrictions.

Policies compose with all, any, and not:

declare const isProjectOwner: Policy.Policy;
declare const hasUpdateGrant: Policy.Policy;
declare const isSuspended: Policy.Policy;

const canEditProject = Policy.any(isProjectOwner, hasUpdateGrant);

const canEditAndNotSuspended = Policy.all(
  canEditProject,
  Policy.not(isSuspended, "account-disabled")
);

Step-up gates live in @effect-auth/core/StepUp and can be converted to policies when a product action requires stronger or fresher auth:

import { Duration } from "effect";
import * as Policy from "@effect-auth/core/Policy";
import * as StepUp from "@effect-auth/core/StepUp";

const canGenerateApiKey = StepUp.toPolicy({
  aal: "aal2",
  maxAge: Duration.minutes(15),
});

const generateApiKey = createApiKey.pipe(Policy.require(canGenerateApiKey));

Rate Limiter

RateLimiter is a small auth-oriented wrapper around Effect's persistent rate limiter. It keeps the public auth API focused on policies and safe keys while delegating fixed-window/token-bucket mechanics to effect/unstable/persistence.

import { Duration, Effect } from "effect";
import { EmailHash, type IpHash } from "@effect-auth/core/Identifiers";
import * as RateLimit from "@effect-auth/core/RateLimiter";

declare const emailHash: EmailHash;

const PasswordSignInEmailLimit = Effect.gen(function* () {
  return yield* RateLimit.RateLimitPolicy.fixedWindow({
    id: "auth.password.sign_in.email",
    key: yield* RateLimit.RateLimitKey.emailHash(emailHash),
    limit: 5,
    window: Duration.minutes(10),
  });
});

const signIn = Effect.gen(function* () {
  yield* RateLimit.require(yield* PasswordSignInEmailLimit);

  return yield* Effect.succeed({ ok: true });
});

fixedWindowBy and tokenBucketBy accept any safe RateLimitKey, including composite keys built from other safe keys:

declare const ipHash: IpHash;

const PasswordSignInIpEmailLimit = Effect.gen(function* () {
  const key = yield* RateLimit.RateLimitKey.combine(
    "ip-email",
    yield* RateLimit.RateLimitKey.ipHash(ipHash),
    yield* RateLimit.RateLimitKey.emailHash(emailHash)
  );
  return yield* RateLimit.RateLimitPolicy.fixedWindowBy({
    id: "auth.password.sign_in.ip_email",
    by: key,
    limit: 5,
    window: Duration.minutes(10),
  });
});

Key and policy constructors are effectful and return value-free SecurityConfigurationError failures. RateLimitKey.digest accepts only canonical lowercase hexadecimal or base64url SHA-256 digests; the typed emailHash, ipHash, and userAgentHash helpers enforce the same runtime syntax. Raw emails, IDs, IPs, tokens, and user agents are not accepted as store keys. PrivacyLive({ secret }) provides production HMAC hashing with conservative normalization. Use a dedicated secret such as AUTH_PRIVACY_SECRET, separate from session and challenge secrets. RateLimiterMemoryLive is process-local for tests/dev, and production deployments can provide a persistent Effect RateLimiterStore such as Redis or @effect-auth/core/CloudflareRateLimitDurableObject.

import { Redacted } from "effect";
import { PrivacyLive } from "@effect-auth/core/Privacy";

const AuthPrivacyLive = PrivacyLive({
  secret: Redacted.make(env.AUTH_PRIVACY_SECRET),
});

The built-in HTTP auth API consumes 54 validated standard operation policies through AuthRateLimitStandardLive(config). This includes maintained upstream OAuth start/unlink, provider authorization, token ingress/token/introspection/revocation, and device start/poll/inspect/approve/deny routes. Every token request uses the independent 300/10m auth.oauth.token.ingress IP budget before form decoding; decoded device grants then use the 120/10m poll IP and poll composites instead of the standard token operation, while other supported grants use the 60/10m token IP and applicable client/credential rules. OAuth stages use HMAC-isolated IP, authenticated user, secret credential, and canonical IP+provider/client/device-code or user+user-code/authorization composites. Public identifiers never create global limiter buckets. Production deployments provide a durable RateLimiter store and HMAC Privacy secret. In config, an undefined operation uses its standard rules, a rule array replaces them, and null disables that operation. AuthServicesLive below stands for your app-owned auth service wiring.

import { Duration, Layer, Redacted } from "effect";
import { RateLimiter as PersistenceRateLimiter } from "effect/unstable/persistence";
import { AuthRateLimitStandardLive } from "@effect-auth/core/AuthRateLimit";
import { CoreAuthHttpApiLive } from "@effect-auth/core/HttpApi";
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 {
  RateLimitStoreDurableObject,
  type CloudflareRateLimitDurableObjectNamespace,
} from "@effect-auth/core/CloudflareRateLimitDurableObject";
import { PrivacyLive } from "@effect-auth/core/Privacy";
import { RateLimiterLive } from "@effect-auth/core/RateLimiter";

const HttpEndpointCapabilitiesLayer = httpEndpointCapabilitiesLayerNoDeps({
  passwordEmailVerification: PasswordEmailVerificationCapability.Disabled(),
  loginNotificationReport: LoginNotificationReportCapability.Disabled(),
  loginApprovalStatus: HttpLoginApprovalStatusCapability.Disabled(),
  loginApprovalFinalizer: HttpLoginApprovalFinalizerCapability.Disabled(),
});

export const makeAuthHttpApiLayer = (
  namespace: CloudflareRateLimitDurableObjectNamespace,
  privacySecret: string
) => {
  const DurableObjectRateLimiterLive = RateLimiterLive.pipe(
    Layer.provide(PersistenceRateLimiter.layer),
    Layer.provide(RateLimitStoreDurableObject.layer({ namespace }))
  );

  return CoreAuthHttpApiLive.pipe(
    Layer.provide(AuthServicesLive),
    Layer.provide(HttpEndpointCapabilitiesLayer),
    Layer.provide(
      httpAuthenticationCapabilitiesLayerNoDeps({
        requestMetadata: {
          ipSource: { _tag: "CloudflareConnectingIp" },
        },
        botVerifier: HttpBotVerifierCapability.Disabled(),
        trustedDeviceCookie: HttpTrustedDeviceCookieCapability.Disabled(),
        loginRiskEnricher: HttpLoginRiskEnricherCapability.Disabled(),
      })
    ),
    Layer.provide(
      AuthRateLimitStandardLive({
        // Keep standard sign-in rules, replace sign-up, disable reset-start.
        "auth.password.sign_up": [
          {
            id: "app.password.sign_up.ip",
            key: "ip",
            limit: 5,
            window: Duration.hours(1),
          },
        ],
        "auth.password.reset_start": null,
      })
    ),
    Layer.provide(DurableObjectRateLimiterLive),
    Layer.provide(PrivacyLive({ secret: Redacted.make(privacySecret) }))
  );
};

AuthRateLimitStandardLive() applies all standard rules. The explicit escape hatch is AuthRateLimitNoopLive; use it only when another boundary enforces equivalent controls or while deliberately running without them:

import { Layer } from "effect";
import { AuthRateLimitNoopLive } from "@effect-auth/core/AuthRateLimit";
import { CoreAuthHttpApiLive } from "@effect-auth/core/HttpApi";
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";

const HttpEndpointCapabilitiesLayer = httpEndpointCapabilitiesLayerNoDeps({
  passwordEmailVerification: PasswordEmailVerificationCapability.Disabled(),
  loginNotificationReport: LoginNotificationReportCapability.Disabled(),
  loginApprovalStatus: HttpLoginApprovalStatusCapability.Disabled(),
  loginApprovalFinalizer: HttpLoginApprovalFinalizerCapability.Disabled(),
});

export const AuthHttpApiLayer = CoreAuthHttpApiLive.pipe(
  Layer.provide(AuthServicesLive),
  Layer.provide(HttpEndpointCapabilitiesLayer),
  Layer.provide(AuthRateLimitNoopLive),
  Layer.provide(
    httpAuthenticationCapabilitiesLayerNoDeps({
      botVerifier: HttpBotVerifierCapability.Disabled(),
      trustedDeviceCookie: HttpTrustedDeviceCookieCapability.Disabled(),
      loginRiskEnricher: HttpLoginRiskEnricherCapability.Disabled(),
    })
  )
);

Maintained rate-limited operation Layers, including OAuth and device operations, require both AuthRateLimit and HttpAuthenticationCapabilities; they never discover or install a fallback. The capability assembly snapshots one request-metadata policy used by standard rate limits, OAuth protocol/device paths, passkey risk context, and other maintained guards. Malformed protocol requests keep their generic OAuth errors, while valid limit denials return 429 with Retry-After and no-store headers. Configure a trusted request-IP source explicitly at the public proxy boundary. Missing selected IP metadata fails closed, and proxy trust plus durable counter cardinality/hot-key capacity remain deployment responsibilities.

Password, login-notification, and login-approval operation Layers also require HttpEndpointCapabilities from @effect-auth/core/HttpApi/HttpEndpointCapabilities. Its four required tagged choices explicitly enable or disable password email-verification delivery, login-notification reporting, approval status, and approval finalization. Enabled choices expose only start, report, status, or complete, respectively. Disabled choices retain the endpoint's documented skip or not-configured response and ignore ambient raw services. Login approval uses HttpAuthenticationCapabilities.trustedDeviceCookie as its only cookie transport choice; enabling domain trusted devices while disabling that HTTP choice fails startup validation.

App-owned workflows can use RateLimiter.require, StepUp/Policy requirements, and Guard.requireAll directly. At an HTTP boundary, pipe the composed effect through mapAuthGuardErrors so rate-limit, step-up, policy, privacy, and permission failures become the declared auth HTTP errors while unrelated application errors remain unchanged.

AuthFlow

Login methods should verify a factor, then hand trusted evidence and its bound user to AuthFlow. Callers must handle the complete tagged result because policy may require MFA or approval instead of issuing a session, and current principal revalidation may return generic invalid credentials.

import * as Effect from "effect/Effect";
import { passwordEvidence } from "@effect-auth/core/Assurance";
import { AuthFlow } from "@effect-auth/core/AuthFlow";
import type {
  CredentialId,
  UnixMillis,
  UserId,
} from "@effect-auth/core/Identifiers";

export const completePassword = (input: {
  readonly userId: UserId;
  readonly credentialId: CredentialId;
  readonly verifiedAt: UnixMillis;
}) =>
  Effect.gen(function* () {
    const authFlow = yield* AuthFlow;
    const result = yield* authFlow.completePrimaryFactor({
      userId: input.userId,
      method: "password",
      evidence: [
        passwordEvidence({
          credentialId: input.credentialId,
          verifiedAt: input.verifiedAt,
        }),
      ],
    });

    return result._tag === "Authenticated"
      ? { status: "authenticated" as const, session: result.session }
      : { status: "continuation" as const, result };
  });

Service contracts expose small constructors for typed layer wiring:

import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import { AuthFlow, AuthResult } from "@effect-auth/core/AuthFlow";

const AuthFlowLive = Layer.succeed(
  AuthFlow,
  AuthFlow.of({
    startPrimaryFactor: () =>
      Effect.succeed(AuthResult.PolicyDenied({ reason: "test" })),
    requireMfa: () =>
      Effect.succeed(AuthResult.PolicyDenied({ reason: "test" })),
    completePrimaryFactor: () =>
      Effect.succeed(AuthResult.PolicyDenied({ reason: "test" })),
    completeMfa: () =>
      Effect.succeed(AuthResult.PolicyDenied({ reason: "test" })),
  })
);

The same pattern is available on core services such as AuditLog.make, Sessions.make, SessionCookie.make, Challenge.make, Crypto.make, Privacy.make, and WaitUntil.make.

The reference flow requires one AuthenticationCapabilities assembly. Policies are always present, while MFA, approval, trusted devices, and notifications use explicit Enabled or Disabled tagged values. Disabled MFA or approval fails closed when its required policy requests that feature; disabled trusted devices produce unknown device context, and disabled notifications explicitly skip delivery. AuthFlow never discovers these services from the ambient context.

The deliberately basic preset makes every permissive choice visible. It is useful for examples and local setups, but it is not production hardening:

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

export const BasicCapabilitiesLive = PermissiveAuthenticationCapabilitiesLayer;

That preset chooses an empty pipeline, allow-unverified email, disabled MFA policy and capability, never-approval policy and capability, unknown/no-op risk, allow recovery, disabled trusted devices, and disabled notifications. Production compositions should use effectful AuthenticationCapabilities.make inside Layer.effect, or provide the input through layerNoDeps; raw Context service assembly bypasses validation and is unsupported. Carried services flatten a bounded safe prototype chain into a frozen null-prototype receiver, with normal own-over-prototype shadowing. Captured inherited data, prototype helpers called through this, and class methods remain stable; referenced Effect Refs and stores remain intentionally live. Accessors, symbols, constructors that do not identify a safe class prototype, native/exotic chains, traps, and excessive depth are rejected. JavaScript private-field-backed methods are unsupported because their private brand cannot be transferred to the snapshot. The effectful auth-flow, terminal, and approval-finalizer factories similarly exact-snapshot their dependency envelopes and required callback identities before capability normalization, then never reread caller dependency objects at runtime. An enabled MFA capability always contains AuthFlowState and an explicit tagged list of TOTP and recovery-code inventory services. An enabled approval capability always contains its flow, state, crypto, and review mode; same-device secrets always use the library's fixed 32-byte entropy request. There is no fake crypto or factor manager.

The pipeline remains part of that assembly. Available phases are before-risk, after-risk, before-mfa, after-mfa, and before-login-approval. Every stage receives mfaSatisfied, so stages can distinguish the initial primary-factor pass from a resumed MFA flow. Stages run again when the flow resumes; side effects that must execute exactly once should use an idempotency key or inspect mfaSatisfied.

The first default live implementations are available for the phase-3 web session path:

import { Layer, Redacted } from "effect";
import { CustomEvidencePoliciesLive } from "@effect-auth/core/Assurance";
import {
  AuthFlowLive,
  PermissiveAuthenticationCapabilitiesLayer,
} from "@effect-auth/core/AuthFlow";
import { ChallengeLive } from "@effect-auth/core/Challenge";
import { WebCryptoLive } from "@effect-auth/core/Crypto";
import {
  PasswordLoginLive,
  PasswordRegistrationLive,
  Pbkdf2PasswordHasherLive,
} from "@effect-auth/core/Password";
import { SessionCookieLive, SessionsLive } from "@effect-auth/core/Sessions";
import { EffectQbSqliteAuthStorageLive } from "@effect-auth/core/EffectQbSqliteStorage";

const AuthLive = Layer.mergeAll(
  WebCryptoLive(),
  EffectQbSqliteAuthStorageLive(executor),
  Pbkdf2PasswordHasherLive(),
  SessionsLive({ secret: Redacted.make(env.AUTH_SESSION_SECRET) }).pipe(
    Layer.provide(CustomEvidencePoliciesLive([]))
  ),
  SessionCookieLive(),
  ChallengeLive({ secret: Redacted.make(env.AUTH_CHALLENGE_SECRET) }),
  PermissiveAuthenticationCapabilitiesLayer,
  AuthFlowLive,
  PasswordLoginLive,
  PasswordRegistrationLive
);

SessionsLive stores only sessionId plus HMAC secret hash in the database. The browser cookie contains the opaque bearer token in sessionId.secret format.

Shared domain keys should come from AuthSecretsFromRootLive or an exact AuthSecretsLive({ session, challenge, privacy }) data object. Both paths detach key material at construction; later caller mutation cannot replace retained authority. Unknown fields, symbols, accessors, and exotic configuration fail through value-free SecurityConfigurationError before crypto consumers run.

Password

PasswordLogin verifies a password factor and hands the result to AuthFlow. It does not create sessions directly.

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

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

  return yield* password.signIn({
    email: Email("[email protected]"),
    password: Redacted.make("correct horse battery staple"),
  });
});

PasswordLoginLive requires UserStore, CredentialStore, PasswordHasher, and AuthFlow. Invalid credentials return AuthResult.InvalidCredentials(), while storage/hash/session failures stay as typed errors.

PasswordRegistration creates a user and password credential, then hands the primary password factor to AuthFlow.

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

export const signUp = Effect.gen(function* () {
  const registration = yield* PasswordRegistration;

  return yield* registration.signUp({
    email: Email("[email protected]"),
    password: Redacted.make("correct horse battery staple"),
  });
});

Duplicate emails fail with EmailAlreadyRegisteredError; callers decide whether to expose that as a conflict, a neutral response, or another boundary-specific behavior.

Production Node and Bun applications should use the runtime-specific Argon2id adapters. Their configuration and shared native-operation concurrency are explicit services:

import { Layer } from "effect";
import { WebCryptoLive } from "@effect-auth/core/Crypto";
import {
  Argon2idPasswordHasherConfigLive,
  PasswordHashingConcurrencyConfigLive,
  PasswordHashingConcurrencyLive,
} from "@effect-auth/core/PasswordArgon2id";
import { NodeArgon2idPasswordHasherLive } from "@effect-auth/core/PasswordArgon2idNode";

const PasswordHashingLive = NodeArgon2idPasswordHasherLive.pipe(
  Layer.provide(PasswordHashingConcurrencyLive),
  Layer.provide(PasswordHashingConcurrencyConfigLive),
  Layer.provide(Argon2idPasswordHasherConfigLive),
  Layer.provide(WebCryptoLive())
);

Use BunArgon2idPasswordHasherLive on Bun. Both adapters emit Argon2id PHC strings, bound PHC parameters before native work, and verify legacy pbkdf2-sha256$... hashes. Successful legacy login requests needsRehash, and the password flow migrates with a compare-and-swap update. Pbkdf2PasswordHasherLive remains an explicit compatibility fallback for runtimes without supported Argon2id APIs.

HTTP API

@effect-auth/core/HttpApi contains the first Effect v4 HttpApi contract for the built-in auth surface. It exports CoreAuthHttpApi, module-owned endpoint groups, and handler layers for the core services.

import { Layer } from "effect";
import { HttpRouter } from "effect/unstable/http";
import { CoreAuthHttpApiLive } from "@effect-auth/core/HttpApi";
import { PrivacyLive } from "./auth/privacy";
import { RateLimiterLive } from "./auth/rate-limiter";
import {
  HttpLoginApprovalFinalizerCapability,
  HttpLoginApprovalStatusCapability,
  LoginNotificationReportCapability,
  PasswordEmailVerificationCapability,
  layerNoDeps as httpEndpointCapabilitiesLayerNoDeps,
} from "@effect-auth/core/HttpApi/HttpEndpointCapabilities";

const HttpEndpointCapabilitiesLayer = httpEndpointCapabilitiesLayerNoDeps({
  passwordEmailVerification: PasswordEmailVerificationCapability.Disabled(),
  loginNotificationReport: LoginNotificationReportCapability.Disabled(),
  loginApprovalStatus: HttpLoginApprovalStatusCapability.Disabled(),
  loginApprovalFinalizer: HttpLoginApprovalFinalizerCapability.Disabled(),
});

export const AuthRoutesLive = HttpRouter.serve(
  CoreAuthHttpApiLive.pipe(
    Layer.provide(AuthServicesLive),
    Layer.provide(HttpEndpointCapabilitiesLayer),
    Layer.provide(RateLimiterLive),
    Layer.provide(PrivacyLive)
  )
);

The contract currently includes these endpoint groups:

| Group | Endpoints | | ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Password | POST /auth/password/sign-in, POST /auth/password/sign-up, POST /auth/password/reset/start, POST /auth/password/reset/verify, POST /auth/password/set, POST /auth/password/change. | | Sessions | GET /auth/session, POST /auth/session/refresh, POST /auth/logout, GET /auth/sessions, POST /auth/sessions/revoke, POST /auth/sessions/revoke-others. | | Email verification | POST /auth/email-verification/start, POST /auth/email-verification/verify. | | Email OTP | POST /auth/email-otp/start, POST /auth/email-otp/verify. | | Magic link | POST /auth/magic-link/start, POST /auth/magic-link/verify. | | Login approval | POST /auth/login-approval/approve, POST /auth/login-approval/status, POST /auth/login-approval/finalize. | | Security | POST /auth/security/login/report. |

CoreAuthHttpApiLive wires the full built-in surface. Apps can own the final HTTP contract and reuse individual endpoints, services, and the public OAuth/WebAuthn protocol adapters when a feature needs app-specific payloads or responses.

Authenticated results set the configured session cookie and never include the opaque session token in JSON bodies. Invalid credentials and disabled accounts map to the same 401 invalid_credentials response by default. Duplicate password sign-up emails fail as EmailAlreadyRegisteredError in the domain layer and map to a 409 email_already_registered HTTP API error.

SessionHttpApiGroup reads the configured session cookie and validates it with Sessions, returning a safe current-session JSON body or 401 unauthenticated. Refresh extends the current session when it is eligible, commits the refreshed session cookie, and returns the safe current-session JSON body. Safe authenticated bodies include session claims when present, but never include the opaque session token. Logout revokes the current session when present and clears the session cookie with a 204 response. @effect-auth/core/HttpApi/Session also exports the standalone SessionHttpApi, SessionHttpApiGroupLive, and SessionHttpApiLive without loading the broad server or client API assemblies.

Email HTTP features have the same focused boundary. @effect-auth/core/HttpApi/EmailAuth, @effect-auth/core/HttpApi/EmailOtp, @effect-auth/core/HttpApi/EmailVerification, and @effect-auth/core/HttpApi/MagicLink export their canonical endpoints, group contracts, operation services, handlers, focused group layers, and standalone API presets without loading HttpApi/Api, HttpApi/ClientApi, or HttpApi/Endpoints. The aggregate API and browser client re-export those same contract objects, so custom and full compositions use identical routes, middleware, statuses, and symbol identity.

@effect-auth/core/HttpApi/Passkey likewise owns the canonical registration, authentication, credential-list, and credential-revocation endpoints plus their handlers and standalone preset. Its runtime graph does not load the broad HTTP assemblies, unrelated password/email/OAuth/TOTP/recovery implementations, or PasskeySimpleWebAuthn. Passkey step-up keeps its /auth/step-up/passkey/* routes in the StepUp group while sharing focused passkey schemas and handlers. Install @simplewebauthn/server only when importing the explicit @effect-auth/core/PasskeySimpleWebAuthn adapter; it is an optional peer dependency of core.

@effect-auth/core/HttpApi/Totp and @effect-auth/core/HttpApi/RecoveryCodes own their canonical schemas, endpoints, groups, input mappers, operation services and layers, handlers, and standalone presets. Each focused graph excludes the broad HTTP assemblies and unrelated password, email, OAuth, passkey, and other strong-factor implementation while sharing the explicit strong-factor removal policy and focused session-rotation ports. The aggregate HTTP API, client protocol, endpoint registry, and schema barrel re-export the same runtime objects for compatibility.

@effect-auth/core/HttpApi/StepUp is the canonical focused current-session step-up assembly for factor discovery plus TOTP, password, recovery-code, and passkey verification. It composes the canonical passkey step-up endpoints without loading the full passkey, password, TOTP, recovery-code, email, or OAuth HTTP implementations. The broad HTTP API, client protocol, endpoint registry, and schema barrel re-export the same runtime objects.

@effect-auth/core/HttpApi/Mfa owns the separate flow-bound login-MFA contract: factor options plus atomic TOTP and recovery-code verification. It exports canonical schemas, endpoints, operations, handlers, group, and standalone preset without loading the broad HTTP assemblies or the full TOTP/recovery-code HTTP implementations. Broad API, client, endpoint, and schema modules preserve those exact object identities through re-exports.

@effect-auth/core/HttpApi/LoginApproval is the canonical focused login-approval HTTP assembly for approval links, review status, and finalization. It exports all three schemas and endpoints, input mappers, handlers, operation layer, focused group layer, and LoginApprovalHttpApiLive without loading the broad API, client, endpoint, or schema modules. The aggregate modules compose and re-export those same runtime objects; the focused handlers retain same-device flow binding, inspect-before-consume verification, active-principal and identity checks, expiry handling, trusted-device cookies, and capability-gated finalization.

@effect-auth/core/HttpApi/LoginNotification is the canonical focused login-notification report assembly. It exports the report schema and endpoint, input mapper, handler, operation service and Layer, focused group Layer, standalone LoginNotificationHttpApi, and LoginNotificationHttpApiLive without loading broad HTTP modules or unrelated login-security implementations. Schemas, Endpoints, ClientApi, and Api compose and re-export those exact runtime objects; disabled report capability remains fail-closed, and report secrets are redacted before reaching the capability service.

Maintained auth cookies use the public BrowserCookie boundary: RFC-token __Host- names, Secure, HttpOnly, Path=/, no Domain, bounded strict parsing, and synchronized Max-Age/Expires with a maximum 400-day lifetime. Secure cookies work on HTTPS loopback development origins; non-loopback plain HTTP is unsupported. Configurable sensitive profiles accept only exact optional name and sameSite data fields; expiry is credential-owned, so maxAge, transport attributes, unknown fields, symbols, accessors, and exotic objects are rejected.

CoreAuthHttpApiLive requires an effectfully validated AuthHttpApiConfigLive with an explicit origin policy. Every method except the exact case-sensitive tokens GET, HEAD, and OPTIONS must present a canonical Origin, or a valid Referer when Origin is absent, whose origin is in the allowlist. Origin wins when both are present, including when it is malformed. Sec-Fetch-Site, Host, originalUrl, and forwarded host/protocol headers never establish authority. Use secure HTTPS mode in production; loopback-development is explicit and accepts only HTTP/HTTPS localhost, IPv4 127/8, and ::1 origins.

Maintained cookie-auth browser APIs deliberately use origin-only CSRF protection with the mandatory exact, fail-closed origin policy. AuthCsrfMiddlewareLive() is an optional primitive for applications that explicitly choose origin plus app-owned double-submit protection. It is not silently mounted because core has no token issuance lifecycle. The middleware compares the __Host-csrf cookie to the x-csrf-token header on every method except exact case-sensitive GET, HEAD, and OPTIONS, and rejects mismatches with 403 request_rejected. App-owned HTTP handlers should use mapAuthGuardErrors to map core guard failures to safe HTTP errors without exposing policy reasons or internal details.

Apps using AuthCsrfMiddleware must own high-entropy token generation, browser delivery, rotation, expiry, and binding to the authenticated session. Issue the token from an app-owned bootstrap endpoint or page render, set the __Host-csrf cookie, and echo the separately delivered value in the x-csrf-token header on non-safe cookie requests:

const csrfToken = crypto.randomUUID();

return new Response(JSON.stringify({ csrfToken }), {
  headers: {
    "content-type": "application/json",
    "set-cookie": `__Host-csrf=${encodeURIComponent(csrfToken)}; Path=/; Secure; HttpOnly; SameSite=Lax`,
  },
});

await fetch("/auth/session/refresh", {
  method: "POST",
  credentials: "include",
  headers: { "x-csrf-token": csrfToken },
});

AuthRequestMetadataMiddlewareLive() can provide method, URL, origin, user-agent, and explicitly sourced proxy IP metadata to app-owned handlers. requestMetadata.ipSource defaults to None. Select exactly CloudflareConnectingIp, XRealIp, or XForwardedFor with 1 through 16 trusted hops. For XFF, core selects addresses.length - trustedHops; the current trusted peer is not an XFF entry. One hop makes both client and spoof, client select client; two hops make spoof, client, trusted-proxy select client. Short, conflicting, or malformed selected data omits IP, so IP limiters use their fail-closed shared missing-IP bucket. Proxy IP selection never affects origin authority. A trusted-hop count is not peer authentication: the proxy must strip/overwrite the selected header and the backend must not be directly reachable.

import { Layer } from "effect";
import { AuthRateLimitStandardLive } from "@effect-auth/core/AuthRateLimit";
import {
  AuthHttpApiConfigLive,
  CoreAuthHttpApiLive,
} from "@effect-auth/core/HttpApi";
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";

const HttpEndpointCapabilitiesLayer = httpEndpointCapabilitiesLayerNoDeps({
  passwordEmailVerification: PasswordEmailVerificationCapability.Disabled(),
  loginNotificationReport: LoginNotificationReportCapability.Disabled(),
  loginApprovalStatus: HttpLoginApprovalStatusCapability.Disabled(),
  loginApprovalFinalizer: HttpLoginApprovalFinalizerCapability.Disabled(),
});

export const AuthHttpApiLayer = CoreAuthHttpApiLive.pipe(
  Layer.provide(HttpEndpointCapabilitiesLayer),
  Layer.provide(
    httpAuthenticationCapabilitiesLayerNoDeps({
      requestMetadata: {
        ipSource: { _tag: "CloudflareConnectingIp" },
      },
      botVerifier: HttpBotVerifierCapability.Disabled(),
      trustedDeviceCookie: HttpTrustedDeviceCookieCapability.Disabled(),
      loginRiskEnricher: HttpLoginRiskEnricherCapability.Disabled(),
    })
  ),
  Layer.provide(
    AuthHttpApiConfigLive({
      originPolicy: {
        mode: "secure",
        origins: ["https://app.example.com"],
      },
    })
  ),
  Layer.provide(AuthRateLimitStandardLive()),
  Layer.provide(AuthServicesLive),
  Layer.provide(RateLimiterLive),
  Layer.provide(PrivacyLive)
);

HttpAuthenticationCapabilities owns the immutable request-metadata policy used by maintained operations. AuthHttpApiConfigLive separately supplies origin middleware configuration; its origin allowlist does not select limiter or risk metadata.

EmailVerificationHttpApiGroup exposes session-required start and verify endpoints backed by EmailVerificationCode. The preset generates and emails an eight-digit server-side code, binds it to the exact session, user, identity owner, and normalized email value, and returns only the challenge ID and expiry, never the code. Verification atomically consumes the challenge and marks the identity verified through EmailVerificationCommitStore, returns 204, then updates the current session claims. Errors are generic.

Password HTTP handlers can start EmailVerificationFlow automatically when a custom auth flow returns RequiresEmailVerification. The HTTP response remains requires_email_verification and does not include the user id, email, challenge secret, or delivery provider details.

Application workflow handlers belong in the app. Compose domain/application effects with guards, then map only guard failures into the endpoint's HTTP error contract:

import { Duration, Effect, Schema } from "effect";
import {
  HttpApi,
  HttpApiBuilder,
  HttpApiEndpoint,
  HttpApiGroup,
} from "effect/unstable/httpapi";
import * as Guard from "@effect-auth/core/Guard";
import * as RateLimiter from "@effect-auth/core/RateLimiter";
import {
  AuthInternalError,
  AuthRateLimitedError,
  mapAuthGuardErrors,
} from "@effect-auth/core/HttpApi";

const updateProfileEndpoint = HttpApiEndpoint.post("update", "/profile", {
  payload: Schema.Struct({ displayName: Schema.String }),
  success: Schema.Struct({ updated: Schema.Boolean }),
  error: [AuthRateLimitedError, AuthInternalError],
});

const UpdateProfileRateLimit = RateLimiter.RateLimitPolicy.fixedWindow({
  id: "app.profile.update",
  key: RateLimiter.RateLimitKey.global,
  limit: 20,
  window: Duration.minutes(1),
});

class ProfileHttpApiGroup extends HttpApiGroup.make("profile")
  .add(updateProfileEndpoint)
  .prefix("/auth") {}

class AppApi extends HttpApi.make("AppApi").add(ProfileHttpApiGroup) {}

const updateProfile = (displayName: string) =>
  Effect.succeed({ updated: displayName.length > 0 });

export const ProfileHttpApiGroupLive = HttpApiBuilder.group(
  AppApi,
  "profile",
  (handlers) =>
    handlers.handle(
      "update",
      Effect.fn("app.auth.profile.update")(function* ({ payload }) {
        return yield* Effect.gen(function* () {
          const policy = yield* UpdateProfileRateLimit;
          return yield* updateProfile(payload.displayName).pipe(
            Guard.requireAll(RateLimiter.require(policy))
          );
        }).pipe(mapAuthGuardErrors);
      })
    )
);

Use Guard.requireAll with RateLimiter.require(...), AuthRateLimit.require(...), direct StepUp effects, direct Policy effects, or any app-owned guard. This keeps workflow ownership local instead of importing a thin core handler factory. mapAuthGuardErrors preserves unrelated domain errors, so include those separately in the endpoint error schema.

Sessions

Sessions is the server-side low-level session authority. Sessions.create and prepareCreate must not coordinate primary login or bypass AuthFlow; direct callers own every principal, evidence, and policy invariant. Transports such as cookies are separate services so browser, API, and custom transports can share the same session strategy. A non-primary server operation can inspect a user's existing sessions directly:

import * as Effect from "effect/Effect";
import type { UserId } from "@effect-auth/core/Identifiers";
import { Sessions } from "@effect-auth/core/Sessions";

export const listServerSessions = (userId: UserId) =>
  Effect.gen(function* () {
    const sessions = yield* Sessions;

    return yield* sessions.listForUser({ userId });
  });

SessionClaims are a safe, transport-visible session summary. They currently include verifiedIdentityKinds, requirements, and recovery constraints, and are returned by auth success, current-session, and refresh HTTP responses when present. Use Sessions.updateClaims to update an active session without rotating the opaque session token. Effect-QB storage persists claims in the existing auth_session.metadata column, so no additional storage migration is required.

Password auth passes verified identity context into AuthFlow, where the required EmailVerificationSessionPolicy in AuthenticationCapabilities decides whether an unverified email gets a normal session or a constrained session. Use limited-session to issue sessions with an email_verification requirement. Per-login email approval is a separate LoginApprovalPolicy concern, not an email verification session mode.

import {
  makeEmailVerificationSessionPolicy,
  makePermissiveAuthenticationCapabilities,
} from "@effect-auth/core/AuthFlow";
import { layerNoDeps } from "@effect-auth/core/AuthenticationCapabilities";

export const LimitedSessionCapabilitiesLive = layerNoDeps({
  ...makePermissiveAuthenticationCapabilities(),
  emailVerificationSessionPolicy: makeEmailVerificationSessionPolicy({
    mode: "limited-session",
  }),
});

For limited-session, protect sensitive app routes by requiring the current session to be email verified. This policy expects your route middleware to provide CurrentSession.

import { Effect } from "effect";
import * as Policy from "@effect-auth/core/Policy";

export const updateBillingSettings = Effect.gen(function* () {
  return yield* saveBillingSettings();
}).pipe(Policy.require(Policy.requireEmailVerified()));

LoginApprovalPolicy is a separate primary-factor decision for high-security sign-in flows. It decides whether a verified primary factor can continue to session creation or should require app-owned login approval. When LoginApprovalCapability.Enabled contains a LoginApprovalFlow and AuthFlowState, AuthFlow stores the pending primary factor, issues a login-approval challenge, and returns AuthResult.RequiresLoginApproval. If policy requires approval while the capability is disabled, authentication returns PolicyDenied.

The built-in HTTP API completes approval with POST /auth/login-approval/approve using flowId, approvalChallengeId, and an optional challenge secret. It verifies the approval, checks the approval belongs to the pending flowId, consumes AuthFlowState, marks the user email verified when the approval requested it, and creates the session. sessionBinding: "originating-device" protects the pending auth flow state with an HttpOnly __Host-login-approval cookie secret; approving from another device fails before session creation. sessionBinding: "approval-device" creates the session on the device that completes approval. sessionBinding: "none" records an approval review but does not create a user session from the approval response; the originating device finalizes later with LoginApprovalFinalize.

Custom policy code is the primary extension point. Presets such as mode: "new-device" are convenience defaults for simple apps; high-security apps should model product-specific risk logic directly in LoginApprovalPolicy.custom. Configured policy construction is effectful and rejects malformed modes, channels, bindings, assurance levels, methods, booleans, and duplicate methods with value-free SecurityConfigurationError.

import { Effect, Layer } from "effect";
import {
  makeLoginApprovalPolicy,
  makePermissiveAuthenticationCapabilities,
} from "@effect-auth/core/AuthFlow";
import {
  AuthenticationCapabilities,
  LoginApprovalCapability,
  LoginApprovalReviewCapability,
} from "@effect-auth/core/AuthenticationCapabilities";

export const AppAuthenticationCapabilitiesLive = Layer.effect(
  AuthenticationCapabilities,
  Effect.gen(function* () {
    const loginApprovalPolicy = yield* makeLoginApprovalPolicy({
      mode: "new-device",
      channel: "email",
      sessionBinding: "originating-device",
      requireForUnverifiedEmail: true,
    });
    return yield* AuthenticationCapabilities.make({
      ...makePermissiveAuthenticationCapabilities(),
      loginApprovalPolicy,
      loginRiskEngine: appLoginRiskEngine,
      loginApproval: LoginApprovalCapability.Enabled({
        flow: appLoginApprovalFlow,
        state: appAuthFlowState,
        crypto: appCrypto,
        review: LoginApprovalReviewCapability.Enabled({
          service: appLoginApprovalReview,
        }),
      }),
    });
  })
);

The permissive preset uses an explicit configuration-free disabled policy; custom assemblies must choose their own policy and tagged capability. Passkeys or other high-assurance methods can bypass preset approval with skipMethods or minimumAssurance. Approval channel and reason are bounded open strings, so apps can model admin, sms, push, manual-review, or risk-specific reasons without extending core. adminReview uses sessionBinding: "none"; it is rejected when approval review is explicitly disabled. LoginApprovalReview.approve / deny record the out-of-band decision, and LoginApprovalFinalize.complete creates the session only for the originating device. Approval and pending-flow TTL defaults and trusted overrides are canonical integer milliseconds from 1ms through 7 days; invalid configured defaults fail construction and invalid operation overrides fail before challenge, entropy, review, or delivery work. AuthFlow and AuthFlowFinalizer read the same required capability assembly, so recovery policy and best-effort notification behavior cannot differ between primary and approval-finalized sessions. Notification failures remain best effort and do not replace a successful session result.

LoginApproval.inspect validates a login approval challenge without consuming it. Use it for GET preview pages or admin panels that need to display pending approval details. For admin, push, or Slack approvals, configure LoginApprovalReview with a persistent store, let the out-of-band actor call approve or deny, then have the user's original browser poll review status and call LoginApprovalFinalize.complete with its pending-cookie secret. The built-in HTTP API exposes this originating-browser path as POST /auth/login-approval/status and POST /auth/login-approval/finalize, also available as client.loginApproval.status(...) and client.loginApproval.finalize(...). EffectQbSqliteAuthStorageLive provides durable LoginApprovalReviewStore; the included LoginApprovalReviewStoreMemoryLive is for tests and local composition.

AuthFlowState stores a short-lived, single-use pending primary-factor result in the existing challenge store. It is the bridge between “password/passkey/OTP verified” and “approval/MFA completed, now create a session”. MFA state retains the explicit allowed factor identifiers selected at flow start; completion rejects factors that were not retained for that flow.

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

export const AppAuthFlowStateLive = AuthFlowStateLive();

Trusted Devices

TrustedDevice is a small boundary for “remember this device” login policy. It stores only an HMAC hash of an opaque device token and returns LoginDeviceContext as unknown, new, or known. LoginApprovalPolicy({ mode: "new-device" }) can use that context to require approval only for first-time devices.

import { Layer, Redacted } from "effect";
import {
  TrustedDeviceCookieLive,
  TrustedDeviceLive,
  TrustedDeviceStoreMemoryLive,
} from "@effect-auth/core/TrustedDevice";

export const AppTrustedDeviceLive = Layer.mergeAll(
  TrustedDeviceStoreMemoryLive,
  TrustedDeviceLive({ secret: Redacted.make("trusted-device-secret") }),
  TrustedDeviceCookieLive()
);

The built-in password sign-in, email OTP verify, and magic-link verify HTTP handlers read the HttpOnly __Host-auth-device cookie only when HttpAuthenticationCapabilities.trustedDeviceCookie is enabled, then pass the token into AuthFlow; AuthFlow resolves it only when TrustedDeviceCapability.Enabled contains the service. Either disabled choice produces explicit unknown context. POST /auth/login-approval/approve accepts rememberDevice: true; its HTTP cookie transport uses the same explicit HttpAuthenticationCapabilities choice, while approval status and finalization use their focused HttpEndpointCapabilities choices. EffectQbSqliteAuthStorageLive provides durable TrustedDeviceStore; the in-memory store is intended for tests and local composition.

Challenge

Challenge is the shared primitive for short-lived proofs: email OTP, magic link, passkey challenges, OAuth state, reset password, and MFA.

import { Duration, Effect, Redacted } from "effect";
import { Challenge } from "@effect-auth/core/Challenge";

export const issueEmailOtp = (email: string, code: string) =>
  Effect.gen(function* () {
    const ch