Skip to content

Custom Auth Provider

The Custom Provider guide builds a Stripe Product provider whose lifecycle handlers need an API key. This guide builds a lazy StripeCredentials service backed by an Auth Provider, so the key comes from the configured Profile, from env vars in CI, or from a refreshable session, never from props or hardcoded config. For a token-shaped credential the whole provider is generated by makeStoredAuthProvider from a field list; the in-repo Neon provider is exactly this shape.

For the profile method contract, CI resolver, registry, and lazy resolution, see Auth Providers.

The service value is an Effect, not a resolved struct:

src/stripe/Credentials.ts
import * as Context from "effect/Context";
import * as Effect from "effect/Effect";
import * as Redacted from "effect/Redacted";
export class StripeCredentials extends Context.Service<
StripeCredentials,
Effect.Effect<{ apiKey: Redacted.Redacted<string> }>
>()("StripeCredentials") {}

Provider Layers are built on every CLI invocation — before any Profile is configured — so nothing may resolve at Layer construction. Consumers run the Effect only when an operation executes, and a session-backed implementation can put refresh logic inside it. Every built-in credentials service has this shape (CloudflareEnvironment, AWSEnvironment, the distilled SDK Credentials tags).

makeStoredAuthProvider turns a field list into a complete provider: interactive prompts, flag-driven configuration (--method stored --set apiKey=...), schema-validated persistence at ~/.alchemy/credentials/{profile}/stripe-stored.json, login/logout, structured details for alchemy profile show, and a typed NeedsReauth failure when the credential file is missing.

src/stripe/AuthProvider.ts
import { AuthError } from "alchemy/Auth/AuthProvider";
import { makeStoredAuthProvider } from "alchemy/Auth/StoredAuthProvider";
import { getEnvRedactedRequired } from "alchemy/Auth/Env";
import * as Effect from "effect/Effect";
import * as Redacted from "effect/Redacted";
export const STRIPE_AUTH_PROVIDER_NAME = "Stripe";
export type StripeResolvedCredentials = {
type: "apiKey";
apiKey: Redacted.Redacted<string>;
source: { type: "stored" | "env" };
};
export const { layer: StripeAuth, storedSchema: StripeStoredCredentials } =
makeStoredAuthProvider<StripeResolvedCredentials>({
provider: STRIPE_AUTH_PROVIDER_NAME,
fields: [
{
name: "apiKey",
label: "Stripe Secret API Key",
secret: true,
validate: (value) =>
value.startsWith("sk_") ? undefined : "Expected an sk_... key",
},
],
toResolved: (values, source) => ({
type: "apiKey",
apiKey: Redacted.make(values.apiKey!),
source: { type: source },
}),
readEnvironment: getEnvRedactedRequired("STRIPE_API_KEY").pipe(
Effect.map((apiKey) => ({
type: "apiKey" as const,
apiKey,
source: { type: "env" as const },
})),
),
environment: [
{
name: "STRIPE_API_KEY",
required: true,
secret: true,
description: "Stripe secret API key.",
},
],
});

fields drive interactive prompts and --set; each field name is also the property persisted in the credentials file, alongside method: "stored" (the --method value). Validation applies to both paths. The generated storedSchema validates credential reads and writes. readEnvironment resolves CI credentials, while environment documents the variables it consumes. Missing stored credentials appear as NeedsReauth in the profile UI.

Bridge the Auth Provider to the credentials service

Section titled “Bridge the Auth Provider to the credentials service”

fromAuthProvider() connects the two: look up the registered provider, load (or interactively configure) the Profile’s { method }, run read, and provide the result as the StripeCredentials Effect:

// src/stripe/Credentials.ts (additions)
import { resolveProviderConfig } from "alchemy/Auth/Profile";
import type { StoredAuthConfig } from "alchemy/Auth/StoredAuthProvider";
import * as Layer from "effect/Layer";
import {
STRIPE_AUTH_PROVIDER_NAME,
type StripeResolvedCredentials,
} from "./AuthProvider.ts";
export const fromAuthProvider = () =>
Layer.effect(
StripeCredentials,
Effect.gen(function* () {
const { resolve } = yield* resolveProviderConfig<
StoredAuthConfig,
StripeResolvedCredentials
>(STRIPE_AUTH_PROVIDER_NAME);
return yield* resolve.pipe(
Effect.map((creds) => ({ apiKey: creds.apiKey })),
Effect.orDie,
Effect.cached,
);
}),
);

Two details matter. Outside CI, resolveProviderConfig applies the standard profile selection order, then returns the stored config or runs configure and persists the answer. In CI it bypasses profiles and returns readEnvironment. A missing explicit local Profile fails with a command to create it. And the value returned to Layer.effect is the Effect (closed by Effect.cached), not its result: nothing resolves until a handler runs it, and within one process read (and its file lock) runs at most once. Every built-in bridge — Neon, Planetscale, Cloudflare — has this exact shape. (Credentials that expire should use cacheUntilExpiry from alchemy/Auth/CredentialsCache instead of Effect.cached, so a long dev session re-resolves instead of keeping a dead token.)

Merge the auth machinery into the same providers() Layer that carries the resource providers, mirroring Neon/Providers.ts:

src/stripe/Providers.ts
import { CredentialsStoreLive } from "alchemy/Auth/Credentials";
import { ProfileStoreLive } from "alchemy/Auth/Profile";
import * as Provider from "alchemy/Provider";
import * as Layer from "effect/Layer";
import { StripeAuth } from "./AuthProvider.ts";
import * as Credentials from "./Credentials.ts";
import { StripeProduct, StripeProductProvider } from "./Product.ts";
export class Providers extends Provider.ProviderCollection<Providers>()(
"Stripe",
) {}
export const providers = () =>
Layer.effect(Providers, Provider.collection([StripeProduct])).pipe(
Layer.provide(StripeProductProvider()),
Layer.provideMerge(Credentials.fromAuthProvider()),
Layer.provideMerge(StripeAuth),
Layer.provideMerge(ProfileStoreLive),
Layer.provideMerge(CredentialsStoreLive),
Layer.orDie,
);

StripeAuth registers the provider so the alchemy CLI discovers it; ProfileStoreLive and CredentialsStoreLive give the auth machinery its file-system services. (Providers that call APIs through an Effect HttpClient also merge FetchHttpClient.layer here, as Neon does.)

Inside reconcile (or delete, read), double-yield the service — the first yield* gets the lazy Effect out of the service, the second runs it:

src/stripe/Product.ts
reconcile: Effect.fn(function* ({ news, output }) {
const { apiKey } = yield* yield* StripeCredentials;
const stripe = new Stripe(Redacted.value(apiKey));
// observe / ensure / sync — see the Custom Provider guide
}),

This is the same idiom the built-ins use — yield* AWSEnvironment.current, yield* yield* CloudflareEnvironment — and it’s what keeps credentials demanded exactly when an operation runs, never at Layer construction.

Terminal window
alchemy deploy # first run: prompts for credentials, saves them to the profile
alchemy profile refresh --provider Stripe # re-authenticate without changing the method
alchemy profile edit --reconfigure Stripe # replace the stored credentials interactively
# non-interactive (agents, scripts): value from env, stdin, or a literal
alchemy profile edit --add stripe --method stored --set apiKey=env:STRIPE_API_KEY
op read op://vault/stripe/key | alchemy profile edit --add stripe --method stored --set apiKey=-
alchemy profile show # renders your provider's details
alchemy provider check-env --provider stripe # CI preflight for STRIPE_API_KEY

In CI, profile resolution is bypassed, so setting STRIPE_API_KEY is all a pipeline needs and no profile file is created.

  • Auth Providers documents the profile contract, CI resolver, registry, and lazy resolution.
  • Custom Provider — the lifecycle handlers these credentials feed.
  • Profiles — switching, inspecting, and storing the per-provider config.