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
| Concern | Application | effect-auth |
|---|---|---|
| Credential protocol | Verify, rate-limit, prevent replay, map subject to user | No assumptions |
| Evidence | Create bounded facts after verification | Revalidate exact policy ID, version, kind, and schema |
| Assurance | Declare a local role and namespaced AMR | Derive AAL, AMR, freshness, and recovery constraints |
| Continuations | Configure policy and render UI | Run built-in MFA, login approval, session creation, and cookie commitment |
| HTTP/client | Own route, request schema, middleware, and extension | Validate 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.
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",
}),
});| Rule | Consequence |
|---|---|
| New evidence semantics | Publish a new positive policyVersion |
| Referenced session or pending flow exists | Keep that exact policy version registered |
| Missing, malformed, or mismatched policy | Typed integrity failure; no session |
| Custom primary + independent TOTP | At least local aal2 |
| Evidence without a primary role | Cannot 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.
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.
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.
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:
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 now | Application-owned or deferred |
|---|---|
| Custom primary evidence and policy versions | Credential enrollment, hashing, revocation, lockout, and recovery |
| Built-in TOTP/recovery-code MFA and login approval afterward | Custom factor registration in client.mfa or client.stepUp |
| Session validation, refresh, audit, and structured Step-up evaluation | Device-bound PIN attestation and replay protocol |
| Typed custom HttpApi and client extension | Transaction/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.