---
title: "Custom"
url: "https://effect-auth.itsbroly.com/authentication/custom/"
description: "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.

<CalloutContainer type="info">
  <CalloutTitle>
    Choose the narrowest seam
  </CalloutTitle>

  <CalloutDescription>
    If only routes or payloads differ, reuse [HTTP Operations](/concepts/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.
  </CalloutDescription>
</CalloutContainer>

## Contract [#contract]

```mermaid title="A custom verifier enters the maintained authentication pipeline at one typed boundary"
flowchart LR
  B[Browser] --> W[Cloudflare Worker]
  W --> V[Application verifier]
  V --> E[Typed custom evidence]
  E --> A[AuthFlow]
  A --> S[Session cookie]
  A --> M[Built-in MFA or approval]
  M --> S
```

| 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 [#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.

```ts title="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",
  }),
});
```

| 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 [#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.

```ts title="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 [#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.

```ts title="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 [#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.

```ts title="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 [#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:

```ts title="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 [#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](/recipes/add-a-custom-authentication-method/). For standard workflows under custom routes, use [Custom Auth API](/guides/custom-auth-api/).

