effect-auth

Security Policies

Configure effect-auth request policy, rate limiting, origin checks, and Cloudflare persistence.

Standard HTTP Operations apply operation-specific request security. RequestSecurityPolicy can combine AuthRateLimit with bot protection; application authorization remains separate because rate limiting cannot decide tenant, role, ownership, or business access.

Request security and application authorization protect different boundaries

Some user-keyed policies validate the session before constructing their trusted user key; many session operations have no standard rate-limit rule. Treat each operation's implementation and documented policy as authoritative rather than assuming one universal order.

Durable Cloudflare policy

Alchemy v2 binds the Durable Object namespace to the auth Worker. Build the persistent limiter from that binding, then provide the standard policy once to HTTP Operations:

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(),
});

const AppRateLimiterLive = RateLimiterLive.pipe(
  Layer.provide(PersistenceRateLimiter.layer),
  Layer.provide(
    RateLimitStoreDurableObject.layer({ namespace: env.RATE_LIMITER })
  )
);

const AuthApiLive = CoreAuthHttpApiLive.pipe(
  Layer.provide(AuthRateLimitStandardLive()),
  Layer.provide(HttpAuthenticationCapabilitiesLive),
  Layer.provide(HttpEndpointCapabilitiesLayer),
  Layer.provide(Layer.merge(AppAuthServicesLive, AppRateLimiterLive))
);

The operation Layer captures both AuthRateLimit and one HttpAuthenticationCapabilities service, then uses that service's exact request-metadata snapshot for every maintained limiter input. Configure HttpAuthenticationCapabilitiesLive with CloudflareConnectingIp at a Cloudflare boundary or the deployment's validated X-Forwarded policy; do not rely on AuthHttpApiConfig or ambient raw capability services to change an already constructed operation. Presets and custom groups only bind that operation; wrapping it in the same policy again consumes budget twice. Primitive-only endpoints must arrange equivalent controls themselves. RateLimiterMemoryLive is process-local and unsuitable when counters must survive Worker isolate replacement.

Standard limits

Defaults use fixed windows. Multiple rules for one operation are required sequentially in one composed policy; consumption is not atomic, so an earlier counter remains consumed if a later rule denies. Policy constructors validate and freeze 1 through 16 rules; auth operation overrides validate and snapshot 1 through 8 rules when their Layer is built.

The standard profile contains exactly 54 operations. OAuth handlers select disjoint pre-authentication and post-validation subsets from the rows below; they do not consume the same rule twice during one request.

OperationExact standard rules (id, staged key, limit/window)
auth.password.sign_inauth.password.sign_in.ip, ip, 20/10m; auth.password.sign_in.principal, principal, 5/10m
auth.password.sign_upauth.password.sign_up.ip, ip, 10/1h; auth.password.sign_up.principal, principal, 3/1h
auth.password.reset_startauth.password.reset_start.ip, ip, 10/10m; auth.password.reset_start.principal, principal, 3/10m
auth.password.reset_verifyauth.password.reset_verify.ip, ip, 20/10m
auth.email_verification.startauth.email_verification.start.ip, ip, 10/10m; auth.email_verification.start.email, email, 3/10m; auth.email_verification.start.user, user, 3/10m; auth.email_verification.start.identity, principal, 3/10m
auth.email_verification.verifyauth.email_verification.verify.ip, ip, 20/10m; auth.email_verification.verify.challenge, challenge, 5/10m; auth.email_verification.verify.user, user, 5/10m
auth.email_otp.startauth.email_otp.start.ip, ip, 10/10m; auth.email_otp.start.email, email, 5/10m
auth.email_otp.verifyauth.email_otp.verify.ip, ip, 20/10m; auth.email_otp.verify.challenge, challenge, 5/10m; auth.email_otp.verify.principal, principal, 5/10m; auth.email_otp.verify.email, email, 5/10m
auth.email.startauth.email.start.ip, ip, 10/10m; auth.email.start.email, email, 5/10m
auth.magic_link.startauth.magic_link.start.ip, ip, 10/10m; auth.magic_link.start.email, email, 5/10m
auth.magic_link.verifyauth.magic_link.verify.ip, ip, 20/10m
auth.passkey.registration_startauth.passkey.registration_start.user, user, 10/1h
auth.passkey.registration_finishauth.passkey.registration_finish.user, user, 30/10m
auth.passkey.authentication_startauth.passkey.authentication_start.ip, ip, 20/10m
auth.passkey.authentication_finishauth.passkey.authentication_finish.ip, ip, 30/10m
auth.passkey.credentials.listauth.passkey.credentials.list.user, user, 60/1m
auth.passkey.credentials.revokeauth.passkey.credentials.revoke.user, user, 20/10m
auth.identity.availabilityauth.identity.availability.user, user, 60/1m
auth.identity.listauth.identity.list.user, user, 60/1m
auth.identity.addauth.identity.add.user, user, 20/10m
auth.identity.replaceauth.identity.replace.user, user, 20/10m
auth.identity.revokeauth.identity.revoke.user, user, 20/10m
auth.identity.set_primaryauth.identity.set_primary.user, user, 20/10m
auth.totp.enrollment_startauth.totp.enrollment_start.user, user, 10/1h
auth.totp.enrollment_confirmauth.totp.enrollment_confirm.user, user, 20/10m
auth.totp.verifyauth.totp.verify.user, user, 20/10m
auth.totp.factors.listauth.totp.factors.list.user, user, 60/1m
auth.totp.factors.revokeauth.totp.factors.revoke.user, user, 20/10m
auth.recovery_code.generateauth.recovery_code.generate.user, user, 10/1h
auth.recovery_code.regenerateauth.recovery_code.regenerate.user, user, 10/1h
auth.recovery_code.verifyauth.recovery_code.verify.user, user, 20/10m
auth.recovery_code.listauth.recovery_code.list.user, user, 60/1m
auth.recovery_code.revokeauth.recovery_code.revoke.user, user, 20/10m
auth.mfa.optionsauth.mfa.options.ip, ip, 30/10m
auth.mfa.totp_verifyauth.mfa.totp_verify.ip, ip, 20/10m
auth.mfa.recovery_code_verifyauth.mfa.recovery_code_verify.ip, ip, 20/10m
auth.step_up.optionsauth.step_up.options.user, user, 60/1m
auth.step_up.totp_verifyauth.step_up.totp_verify.user, user, 20/10m
auth.step_up.password_verifyauth.step_up.password_verify.user, user, 20/10m
auth.step_up.recovery_code_verifyauth.step_up.recovery_code_verify.user, user, 20/10m
auth.step_up.passkey_startauth.step_up.passkey_start.user, user, 30/10m
auth.step_up.passkey_verifyauth.step_up.passkey_verify.user, user, 30/10m
auth.oauth.authorization_startauth.oauth.authorization_start.ip, ip, 20/10m; auth.oauth.authorization_start.ip_provider, ip-provider, 10/10m
auth.oauth.provider_authorizeauth.oauth.provider_authorize.ip, ip, 60/10m; auth.oauth.provider_authorize.ip_client, ip-client, 30/10m; auth.oauth.provider_authorize.user, user, 30/10m
auth.oauth.token.ingressauth.oauth.token.ingress.ip, ip, 300/10m
auth.oauth.tokenauth.oauth.token.ip, ip, 60/10m; auth.oauth.token.ip_client, ip-client, 30/10m; auth.oauth.token.authorization_code, authorization-code, 10/10m; auth.oauth.token.refresh_token, refresh-token, 20/10m
auth.oauth.token.introspectauth.oauth.token.introspect.ip, ip, 60/10m; auth.oauth.token.introspect.ip_client, ip-client, 60/10m
auth.oauth.token.revokeauth.oauth.token.revoke.ip, ip, 60/10m; auth.oauth.token.revoke.ip_client, ip-client, 60/10m
auth.oauth.account_unlinkauth.oauth.account_unlink.user, user, 20/10m
auth.oauth.device_authorization.startauth.oauth.device_authorization.start.ip, ip, 20/10m; auth.oauth.device_authorization.start.ip_client, ip-client, 10/10m
auth.oauth.device_authorization.pollauth.oauth.device_authorization.poll.ip, ip, 120/10m; auth.oauth.device_authorization.poll.ip_client, ip-client, 120/10m; auth.oauth.device_authorization.poll.ip_device_code, ip-device-code, 120/10m
auth.oauth.device_authorization.inspectauth.oauth.device_authorization.inspect.ip, ip, 20/10m; auth.oauth.device_authorization.inspect.user_user_code, user-user-code, 10/10m
auth.oauth.device_authorization.approveauth.oauth.device_authorization.approve.user, user, 20/10m; auth.oauth.device_authorization.approve.user_authorization, user-authorization, 20/10m
auth.oauth.device_authorization.denyauth.oauth.device_authorization.deny.user, user, 20/10m; auth.oauth.device_authorization.deny.user_authorization, user-authorization, 20/10m

Use exact exported operation keys from AuthRateLimitStandardRules for overrides. The staged key column is also the exact key set accepted for that operation; unsupported staged keys fail configuration validation.

Override semantics

Configuration valueMeaning
OmittedKeep standard rules
Non-empty rule arrayReplace all defaults for that operation
nullDisable policy for that operation
AuthRateLimitStandardLive({
  "auth.password.sign_in": [
    {
      id: "app.password.sign_in.ip",
      key: "ip",
      limit: 8,
      window: Duration.minutes(15),
    },
  ],
  "auth.magic_link.verify": null,
});

Rules support exact fixed-window and token-bucket algorithms. Empty arrays, unknown operations, unsupported staged keys, duplicate IDs/identities, malformed numbers/durations, and more than eight rules fail with value-free SecurityConfigurationError during Layer construction. Provider-authorization replacements must retain a pre-session ip rule; use null for an explicit opt-out rather than a user-only rule that would disappear for anonymous requests. Request-local Rules are revalidated before Privacy or persistence and malformed values map to a sanitized internal failure. Missing required subjects, including the selected trusted IP, fail closed. Password sign-in, sign-up, and reset-start principal rules use the configured identity registry's normalized identity; malformed or unsupported identities use a bounded generic fallback without performing an account lookup before limiting. Use null, Skip, or AuthRateLimitNoopLive only when another explicit control owns the risk.

Keys, proxies, and browser boundaries

ConcernBehavior / Cloudflare decision
Email limiter keyNormalized email, namespaced HMAC-SHA-256 via Privacy.layerNoDeps
IP limiter keyTrimmed IP text, namespaced HMAC; equivalent IPv6 spellings are not canonically collapsed
Trusted subject keyUser, principal, OAuth provider/client/code/token/user-code/authorization namespaces use HMAC-SHA-256; public OAuth IDs are combined with trusted IP or user keys before persistence
Proxy IP metadataNone by default; select one tagged source in HttpAuthenticationCapabilities, with the split Worker using only CloudflareConnectingIp
Non-safe-method CSRF checkAuthOriginCheckMiddleware exempts only exact case-sensitive GET, HEAD, and OPTIONS tokens; every other method requires allowlisted Origin, or Referer only when Origin is absent
Missing origin evidenceAlways rejected for non-safe methods; Fetch Metadata and request/forwarded hosts never substitute
CORSSeparate response/preflight policy, especially with credentials
Double-submit CSRFSeparate AuthCsrfMiddleware primitive; not automatically mounted by built-in contracts

Maintained cookie-auth browser APIs make an explicit origin-only choice and require the exact fail-closed origin policy. Core does not mount AuthCsrfMiddleware because it does not own CSRF-token issuance or browser delivery. Applications may instead choose origin plus app-owned double-submit, but must then own token entropy, delivery, rotation, expiry, and session binding; this is not an optional boolean on the maintained API.

Finally, compose session, role, tenant, ownership, recent-step-up, and business-resource guards outside AuthRateLimit. See App-owned Guards and Abuse Protection.

Rate-limit keys accept only canonical lowercase hexadecimal or base64url SHA-256 digests and use bounded versioned length framing for persistence identity. Persistence responses, Cloudflare RPC values, and versioned Durable Object state are checked structurally and fail closed as RateLimitStoreError when malformed. The Cloudflare adapter still uses one object per framed key; cardinality, hot-key capacity, and sharding remain deployment infrastructure work.

Maintained browser OAuth/device routes expose the standard auth 429 shape. OAuth protocol endpoints expose temporarily_unavailable with status 429. Both install a whole-second Retry-After, Cache-Control: no-store, and Pragma: no-cache; backend and Privacy failures are sanitized and fail closed without a retry header.

Provider-mode OAuth authorization codes use whole-second lifetimes from 1 second through 10 minutes and default to 5 minutes. Explicit expiresAt and expiresIn are mutually exclusive, and an explicit absolute expiry must also produce a whole-second delta from issuance. Device authorizations default to 10 minutes and accept 1 second through 7 days; their initial whole-second poll interval must be at least 1 second and no longer than the authorization lifetime. Every early poll atomically adds exactly 5 seconds, so an evolved bounded interval may place the next permitted poll after authorization expiry. The reference opaque issuers default access tokens to 1 hour and refresh tokens to 30 days, bound them to 24 hours and 365 days respectively, and require the access lifetime not to exceed the refresh lifetime when a refresh token is issued. These constructors snapshot validated TTLs and device user-code HMAC key material; issue paths reject malformed Unix-millisecond time or unsafe expiry addition before RNG, hashing, or storage. Verification and maintained memory, SQLite/D1, and PostgreSQL boundaries reject unsafe, fractional, future-issued, overlong, or incoherent authorization, poll, token, and family records without activating or writing them. Refresh rotation preserves the original family expiry and caps replacement access tokens at that boundary.

Handler-owned form parsing for device start, token, introspection, and revocation is IP-limited before content-type checks, body decoding, grant selection, or client credential parsing. Every token request uses only auth.oauth.token.ingress.ip at that pre-body stage. After grant decoding, authorization-code, refresh-token, and client-credentials requests use the auth.oauth.token IP stage plus disjoint applicable client and credential stages; device-code requests use the auth.oauth.device_authorization.poll IP stage plus disjoint client and device-code stages instead. The ingress, token IP, and poll IP operations can be overridden independently, and no operation/rule identity is consumed twice during one request. Framework-owned schema/query decoding for provider authorization and device browser routes still occurs before operation handlers. Only those pre-handler framework failures remain outside operation budgets; deployments that must budget them need a payload-independent limit at a trusted gateway or framework middleware boundary.

On this page