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.
The contract
Section titled “The contract”An Auth Provider is a named record of five profile methods plus an optional CI environment resolver and its declared environment contract:
// alchemy/Auth/AuthProviderexport 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/AuthProviderexport 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.
Credentials are lazy Effects
Section titled “Credentials are lazy Effects”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/CloudflareEnvironmentexport 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/Environmentexport 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.
What lifecycle code yields
Section titled “What lifecycle code yields”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 youconst { 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.
How Profiles select an Auth Provider
Section titled “How Profiles select an Auth Provider”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/CloudflareEnvironmentexport 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.
Environment credentials
Section titled “Environment credentials”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.
Refresh in practice
Section titled “Refresh in practice”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.
Where next
Section titled “Where next”- Custom Auth Provider — build and register your own.
- Profiles — pick, switch, and inspect the stored auth config.
- Custom Provider — the lifecycle side that consumes these credentials.