Skip to content

Auth Providers

Resources never take API keys as props. Each cloud registers an Auth Provider — the service that produces credentials for that cloud’s API calls. A Profile stores which auth method you picked; the Auth Provider turns that choice into live credentials whenever lifecycle code actually calls the cloud.

An Auth Provider is a named record of five profile methods plus an optional CI environment resolver and its declared environment contract:

// alchemy/Auth/AuthProvider
export interface AuthProviderImpl<
Config extends { method: string },
Credentials,
> {
readonly configSchema: Schema.Codec<Config>;
configure(profileName: string): Effect.Effect<Config, AuthError>;
configureWith?(
profileName: string,
input: { method: string; values: Record<string, string> },
): Effect.Effect<Config, AuthError>;
readonly configureMethods?: ReadonlyArray<ConfigureMethod>;
login(profileName: string, config: Config): Effect.Effect<void, AuthError>;
logout(profileName: string, config: Config): Effect.Effect<void, AuthError>;
details(
profileName: string,
config: Config,
): Effect.Effect<ProviderDetails, AuthError | NeedsReauth>;
read(
profileName: string,
config: Config,
): Effect.Effect<Credentials, AuthError | NeedsReauth>;
readonly readEnvironment?: Effect.Effect<Credentials, AuthError>;
readonly environment?: ReadonlyArray<EnvironmentVariable>;
}

configure handles interactive setup. configureWith handles --method and --set; its inputs are declared by configureMethods. login and logout manage stored secrets, details supplies redacted profile status, and read resolves credentials.

configSchema describes the provider’s manifest entry. Stored entries are user-editable JSON that may come from another alchemy version. Alchemy decodes every load against this schema. An invalid entry fails with a reconfigure hint instead of reaching provider code that matches exhaustively on method. The makeStoredAuthProvider factory supplies it automatically.

details and read use the tagged NeedsReauth error for missing, expired, or rotated credentials. The profile UI renders it as “needs re-login.”

readEnvironment never receives or creates a profile. It is used whenever every required variable in the provider’s contract is present in config (the process environment, .env, or --env-file), taking precedence over any selected profile, and always when CI=true. See environment variables outside CI. environment declares every variable readEnvironment consumes (name, required, secret, description, alternatives). Alchemy validates this declaration during registration. Registration fails when a provider has readEnvironment but no declaration. alchemy provider check-env uses the declaration as a CI preflight.

Providers register by name into a single AuthProviders registry via AuthProviderLayer, inside each cloud’s providers() Layer:

// alchemy/Auth/AuthProvider
export class AuthProviders extends Context.Service<
AuthProviders,
{
[providerName: string]: AuthProvider;
}
>()("AuthProviders") {}

That’s how alchemy profile edit works: it imports your stack, reads the registry, and runs the selected providers’ configure/login. The factory also wraps read and logout in a cross-process file lock (so two processes never refresh credentials simultaneously) and serializes interactive flows so prompts from different providers don’t interleave. The file lock does not cover configure or login, because a browser grant may take minutes and block every concurrent read. A provider whose login path silently refreshes a rotate-on-use token takes the lock around just that read-refresh-persist section itself.

Nothing in alchemy holds credentials as a plain value. The per-cloud environment services are Context.Services whose service value is itself an Effect:

// alchemy/Cloudflare/CloudflareEnvironment
export class CloudflareEnvironment extends Context.Service<
CloudflareEnvironment,
Effect.Effect<CloudflareResolvedCredentials>
>()("Cloudflare::CloudflareEnvironment") {
readonly kind = "Environment" as const;
}

AWS is the same shape, and goes one level deeper — the resolved environment holds its credentials as an Effect too, so expiring SSO and assumed-role sessions re-resolve on each access:

// alchemy/AWS/Environment
export interface AWSEnvironmentShape {
accountId: AccountID;
region: RegionID;
credentials: Effect.Effect<ResolvedCredentials, CredentialsError>;
endpoint?: string;
profile?: string;
}
export class AWSEnvironment extends Context.Service<
AWSEnvironment,
Effect.Effect<AWSEnvironmentShape>
>()("AWS::Environment") {
static current = AWSEnvironment.use((env) => env);
readonly kind = "Environment" as const;
}

This laziness is the design point. Provider Layers are built on every CLI invocation — before a Profile may even exist — so the Layer can’t bake in a resolved key. Handing consumers an Effect instead means resolution happens at the point of use, and the Effect can embed refresh logic: an OAuth token or IAM role session renews itself instead of pinning whatever key was live at startup.

Inside reconcile/read/delete, a handler yields the environment twice — once to get the Effect out of the service, once to run it:

// Cloudflare (packages/alchemy/src/Cloudflare/Calls/App.ts)
const { accountId } = yield* yield* CloudflareEnvironment;
// AWS — AWSEnvironment.current does the double yield for you
const { accountId, region } = yield* AWSEnvironment.current;

Most handlers never touch credentials at all. They call distilled SDK operations, and those operations require the SDK’s Credentials service — again a lazy Effect:

// @distilled.cloud/cloudflare (Neon and Planetscale are the same shape)
export class Credentials extends Context.Service<
Credentials,
Effect.Effect<ResolvedCredentials, CredentialsError, never>
>()("CloudflareCredentials") {}

The distilled HTTP client re-runs that Effect on every API request, so whatever refresh logic the Effect embeds runs per-request. Each cloud’s providers() Layer supplies this service from the active Profile’s Auth Provider — the handler just calls the API.

A Profile stores one { method } config per provider in ~/.alchemy/profiles.json (secrets live separately under ~/.alchemy/credentials/{profile}/). Each cloud bridges that config to its credential service with a fromProfile/fromAuthProvider Layer:

// alchemy/Cloudflare/CloudflareEnvironment
export const fromProfile = () =>
Layer.effect(
CloudflareEnvironment,
Effect.gen(function* () {
const { resolve } = yield* resolveProviderConfig<
CloudflareAuthConfig,
CloudflareResolvedCredentials
>(CLOUDFLARE_AUTH_PROVIDER_NAME);
return yield* resolve.pipe(
Effect.orDie,
Effect.cached,
);
}),
);

Outside CI, resolveProviderConfig selects the current profile and loads its stored config. An unconfigured provider fails with the exact alchemy profile edit command to run. Resolution never starts a login flow. The provider’s read method then materializes credentials. Profile selection (--profile, $ALCHEMY_PROFILE, alchemy profile) is covered in Profiles.

When CI=true, resolveProviderConfig bypasses profile selection and calls the provider’s readEnvironment capability. It does not manufacture an { method: "env" } profile entry or touch the local profile files. See the provider environment-variable table for the exact inputs.

Outside CI, the same capability runs first whenever the provider’s required variables are all present in config (process environment, .env, or --env-file) — even when --profile or ALCHEMY_PROFILE selected a profile. The check is per provider, so one provider can come from the environment while the others in the same run come from the profile. Alchemy logs the variables it used. Unset them to fall back to the profile.

Cloudflare’s OAuth method shows why read is an Effect and not a stored value. Every resolve checks expiry and refreshes proactively:

// alchemy/Cloudflare/Auth/AuthProvider — inside read (method: "oauth")
const fresh =
creds.expires > Date.now() + 10_000
? creds
: yield* OAuthClient.refresh(creds).pipe(
Effect.tap((refreshed) =>
store.write(
profileName,
"cloudflare-oauth",
OAuthClient.OAuthCredentials,
refreshed,
),
),
Effect.mapError(
(e) =>
new AuthError({
message: "Cloudflare OAuth refresh failed. Run: alchemy profile refresh --profile default --provider Cloudflare",
cause: e,
}),
),
);

Because read runs under the cross-process file lock, two concurrent deploys can’t double-spend the single-use refresh token. Users can force the same provider-specific login/refresh operation without changing configuration by running alchemy profile refresh, optionally with a named profile and repeatable --provider filters. profile edit --reconfigure is reserved for changing the method, account, scopes, or other provider configuration. One caveat for precision: the fromProfile Layers above pipe resolution through Effect.cached, so the Profile-level read runs once per process — ongoing refresh happens inside the credential Effects the SDKs re-run per request (and in proactive refreshes like the one above), not by re-reading the Profile from disk on every call.