effect-auth

Custom

Connect an application-owned primary verifier to AuthFlow, built-in MFA, sessions, and typed clients.

A custom method verifies a primary credential that effect-auth does not implement, then enters the maintained authentication pipeline with server-created, schema-backed evidence.

Choose the narrowest seam

If only routes or payloads differ, reuse HTTP Operations. Use a custom method when the verifier or evidence semantics are application-owned. Custom MFA and Step-up verifiers are a separate, deferred extension point.

Contract

A custom verifier enters the maintained authentication pipeline at one typed boundary
ConcernApplicationeffect-auth
Credential protocolVerify, rate-limit, prevent replay, map subject to userNo assumptions
EvidenceCreate bounded facts after verificationRevalidate exact policy ID, version, kind, and schema
AssuranceDeclare a local role and namespaced AMRDerive AAL, AMR, freshness, and recovery constraints
ContinuationsConfigure policy and render UIRun built-in MFA, login approval, session creation, and cookie commitment
HTTP/clientOwn route, request schema, middleware, and extensionValidate PrimaryAuthSuccess and expose typed helpers

Define evidence, not credentials

This credit-union example treats customer-number + PIN as a local aal1 primary. It stores only the credential reference in evidence, never the PIN, hash, attempt counters, or verifier output.

bank-pin-evidence.ts
import { defineCustomEvidence } from "@effect-auth/core/Assurance";
import type { AuthMethod } from "@effect-auth/core/AuthFlow";
import { Schema } from "effect";

export const bankPinMethod = "bank-pin" as const satisfies AuthMethod;

export const BankPinEvidence = defineCustomEvidence({
  policyId: "app.bank-pin",
  policyVersion: 1,
  kind: "bank-pin",
  properties: Schema.Struct({ credentialId: Schema.String }),
  evaluate: () => ({
    role: "primary",
    level: "aal1",
    amr: "app:bank_pin",
  }),
});
RuleConsequence
New evidence semanticsPublish a new positive policyVersion
Referenced session or pending flow existsKeep that exact policy version registered
Missing, malformed, or mismatched policyTyped integrity failure; no session
Custom primary + independent TOTPAt least local aal2
Evidence without a primary roleCannot finalize login

Verify, then enter AuthFlow

The Worker derives request context and time. The verifier owns D1 credential reads, binds the verified credential and internal user ID into evidence, performs a slow password/PIN hash with protected pepper and constant-time comparison, and updates attempt/lockout state atomically. ActivePrincipalGate centrally rechecks that the bound user currently exists and is enabled when entering and completing AuthFlow; the verifier must still reject inactive or revoked credentials.

bank-pin-login.ts
import { AuthFlow } from "@effect-auth/core/AuthFlow";
import { UnixMillis } from "@effect-auth/core/Identifiers";
import { Clock, Effect, Redacted } from "effect";

export const signInWithBankPin = Effect.fn("app.auth.bank_pin.sign_in")(
  function* (input: {
    readonly customerNumber: string;
    readonly pin: string;
    readonly request: LoginRequestContext;
  }) {
    const verifier = yield* BankPinVerifier;
    const authFlow = yield* AuthFlow;
    const verified = yield* verifier.verify({
      customerNumber: input.customerNumber,
      pin: Redacted.make(input.pin),
    });

    return yield* authFlow.completePrimaryFactor({
      userId: verified.userId,
      method: bankPinMethod,
      evidence: [
        BankPinEvidence.make({
          verifiedAt: UnixMillis(yield* Clock.currentTimeMillis),
          properties: { credentialId: verified.credentialId },
        }),
      ],
      request: input.request,
    });
  }
);

LoginRequestContext, BankPinVerifier, and its errors are application types. Never accept userId, verifiedAt, evidence properties, AAL, AMR, tenant, redirect, or trusted request metadata from the browser.

Compose policy once

Sessions capture the registry used by AuthFlow and its finalizer. Add the custom method to MFA policy explicitly; methods replaces the default set.

custom-auth.live.ts
import { CustomEvidencePoliciesLive } from "@effect-auth/core/Assurance";
import {
  AuthFlowLive,
  defaultMfaRequirementMethods,
  MfaRequirementPolicyLive,
} from "@effect-auth/core/AuthFlow";
import { SessionsLive } from "@effect-auth/core/Sessions";
import { Layer } from "effect";

const PoliciesLive = CustomEvidencePoliciesLive([BankPinEvidence.policy]);
const SessionsWithPoliciesLive = SessionsLive().pipe(
  Layer.provide(PoliciesLive)
);
const PinMfaPolicyLive = MfaRequirementPolicyLive({
  methods: [...defaultMfaRequirementMethods, bankPinMethod],
});

export const BankPinAuthFlowLive = AuthFlowLive.pipe(
  Layer.provide(PinMfaPolicyLive),
  Layer.provide(SessionsWithPoliciesLive)
);

Retain the same policy Layer for request-time structured Step-up checks and TOTP/recovery rotation decorators.

Deploy on Cloudflare

Alchemy v2 provisions infrastructure; it does not make the PIN protocol safe. Keep the credential table application-owned in D1 and put online attempt controls behind a Durable Object.

alchemy.run.ts
import * as Alchemy from "alchemy";
import * as Cloudflare from "alchemy/Cloudflare";
import * as Effect from "effect/Effect";
import type { RATE_LIMITER as RateLimitDurableObject } from "./src/auth-worker";

export const Database = Cloudflare.D1.Database("AuthDatabase", {
  migrationsDir: "./migrations",
});
export const RateLimiter = Cloudflare.DurableObject<RateLimitDurableObject>(
  "RATE_LIMITER",
  { className: "RATE_LIMITER" }
);
export const AuthWorker = Cloudflare.Worker("AuthWorker", {
  main: "./src/auth-worker.ts",
  env: { DB: Database, RATE_LIMITER: RateLimiter },
});

export default Alchemy.Stack(
  "BankAuth",
  { providers: Cloudflare.providers(), state: Cloudflare.state() },
  Effect.gen(function* () {
    const worker = yield* AuthWorker;
    return { url: worker.url.as<string>() };
  })
);

Use the release-tested alchemy@2.0.0-beta.63. Apply effect-auth and app-owned PIN migrations during deployment, isolate D1 per stage, protect Alchemy state, and bind secrets through the mechanism required by the pinned Alchemy release.

HTTP boundary

Define a bounded Effect HttpApi request, attach origin/schema/rate-limit policy, sanitize verifier and policy-integrity failures, then commit only the standard result:

bank-pin-operation.ts
const result =
  yield *
  signInWithBankPin({
    customerNumber: payload.customerNumber,
    pin: payload.pin,
    request: trustedRequestContext,
  }).pipe(Effect.mapError(mapBankPinHttpError));

return yield * authHttp.commitPrimaryFactorResult(result);

commitPrimaryFactorResult commits a cookie only for authenticated; MFA, approval, and enrollment remain typed success states. Add the custom endpoint under auth.extensions with defineAuthHttpApiExtension.

Scope

Supported nowApplication-owned or deferred
Custom primary evidence and policy versionsCredential enrollment, hashing, revocation, lockout, and recovery
Built-in TOTP/recovery-code MFA and login approval afterwardCustom factor registration in client.mfa or client.stepUp
Session validation, refresh, audit, and structured Step-up evaluationDevice-bound PIN attestation and replay protocol
Typed custom HttpApi and client extensionTransaction/action confirmation

For a concrete Cloudflare partner protocol, endpoint contract, and client extension, follow Accept a partner assertion on Cloudflare. For standard workflows under custom routes, use Custom Auth API.

On this page