effect-auth

Add OAuth Login

Add an OAuth or OIDC provider to an existing cookie-session application.

Provider login makes your application an OAuth client of Google, GitHub, or another identity provider. It is not effect-auth's provider mode: OAuthProviderAuthorizationHttpApiLive, OAuthTokenHttpApiLive, client registration, consent, introspection, and OIDC discovery are for turning your app into an OAuth server. This recipe uses OAuthHttpApiLive only for starting an upstream login.

OAuth login with state, PKCE, and an app-owned callback

Register the provider

Register https://app.example/auth/oauth/google/callback in the provider console and use that exact scheme, host, path, and trailing-slash form everywhere.

Provider presets supply known endpoints and defaults. makeGenericOidcProvider accepts explicit issuer, authorization, token, userinfo, and JWKS endpoints when no preset fits. Credentials belong in application configuration, not OAuthHttpConfigLive; see Alchemy v2 for Cloudflare bindings.

For an async Cloudflare Worker, Alchemy v2 binds deployment configuration directly onto env. Config values become Cloudflare secret_text bindings in 2.0.0-beta.63:

alchemy.run.ts
import * as Alchemy from "alchemy";
import * as Cloudflare from "alchemy/Cloudflare";
import * as Config from "effect/Config";
import * as Effect from "effect/Effect";

export const AuthWorker = Cloudflare.Worker("AuthWorker", {
  main: "./src/auth-worker.ts",
  env: {
    GOOGLE_CLIENT_ID: Config.string("GOOGLE_CLIENT_ID"),
    GOOGLE_CLIENT_SECRET: Config.redacted("GOOGLE_CLIENT_SECRET"),
  },
});

export type AuthWorkerEnv = Cloudflare.InferEnv<typeof AuthWorker>;

export default Alchemy.Stack(
  "AppAuth",
  { providers: Cloudflare.providers(), state: Cloudflare.state() },
  Effect.gen(function* () {
    const worker = yield* AuthWorker;
    return { url: worker.url.as<string>() };
  })
);
import * as Layer from "effect/Layer";
import {
  makeGoogleOidcProvider,
  OAuthProvidersLive,
  OAuthStateLive,
} from "@effect-auth/core/OAuth";
import { OAuthHttpApiLive } from "@effect-auth/core/HttpApi";

declare const env: {
  readonly GOOGLE_CLIENT_ID: string;
  readonly GOOGLE_CLIENT_SECRET: string;
};

export const google = makeGoogleOidcProvider({
  clientId: env.GOOGLE_CLIENT_ID,
  redirectUri: "https://app.example/auth/oauth/google/callback",
});

const OAuthLoginLive = OAuthHttpApiLive.pipe(
  Layer.provide(OAuthProvidersLive([google])),
  Layer.provide(OAuthStateLive),
  Layer.provide(ExistingChallengeCryptoAndSessionLive)
);

Mount that layer with the existing auth API. OAuthStateLive always creates independent public state, a browser-binding secret, and an S256 PKCE verifier/challenge. It also creates a nonce whenever the provider has an issuer. The start handler commits the opaque credential to the fixed __Host-oauth-flow HttpOnly, Secure cookie and returns only providerId, authorizationUrl, and expiresAt with no-store headers. Provider redirect URI, scopes, and authorization parameters come only from the registered provider profile.

Start in the browser

import { createOAuthClient } from "@effect-auth/core/Client";

const oauth = createOAuthClient();
const started = await oauth.authorization.start({
  providerId: "google",
});
location.assign(started.authorizationUrl);

The maintained start contract intentionally accepts no redirect, scope, PKCE, nonce, authorization-parameter, or metadata overrides. Keep any application continuation in separate integrity-protected application state and allow-list it as a local path.

Own the callback

The callback is application-specific. Parse provider errors generically, read the flow credential only from OAuthFlowCookie, shape the callback payload, and use public OAuth primitives:

import * as Effect from "effect/Effect";
import * as Option from "effect/Option";
import * as Redacted from "effect/Redacted";
import { OAuthFlowCookie } from "@effect-auth/core/HttpApi/OAuthFlowCookie";
import { oauthFlowCookieSameSite } from "@effect-auth/core/OAuth";

const oauthFlowCookie = yield * OAuthFlowCookie;
const flowCookieSameSite = yield * oauthFlowCookieSameSite(google);
const clearFlowCookie = yield * oauthFlowCookie.clear(flowCookieSameSite);
const terminal = <A>(body: A) => ({ body, setCookies: [clearFlowCookie] });

const flowCredential =
  yield *
  oauthFlowCookie
    .read(request)
    .pipe(
      Effect.catchTag("SensitiveCookieError", () =>
        Effect.succeed(Option.none())
      )
    );
if (Option.isNone(flowCredential)) {
  return terminal({ status: "denied" as const });
}

const callback =
  yield *
  completeOAuthCallback({
    provider: google,
    state: query.state,
    flowCredential: flowCredential.value,
    code: OAuthAuthorizationCode(query.code),
    clientAuthentication: {
      method: "client_secret_post",
      clientSecret: Redacted.make(env.GOOGLE_CLIENT_SECRET),
    },
  }).pipe(
    Effect.map(Option.some),
    Effect.catch(() => Effect.succeed(Option.none()))
  );
if (Option.isNone(callback)) {
  return terminal({ status: "denied" as const });
}

const idToken = callback.value.tokens.idToken;
if (idToken === undefined) {
  return terminal({ status: "denied" as const });
}

const verified =
  yield *
  verifyOidcIdToken({
    provider: google,
    idToken,
    nonce: callback.value.state.nonce,
  });
if (!verified.valid) {
  return terminal({ status: "denied" as const });
}

const profile =
  yield *
  normalizeOAuthProfile({
    provider: google,
    claims: verified.claims,
  }).pipe(Effect.provide(OAuthOidcProfileNormalizerLive));

const resolution =
  yield *
  resolveOAuthIdentityBridge({
    identity: verifiedOAuthIdentityFromProfile(profile),
    currentUserId,
    makeUser,
    makeAccount,
    makeEmailIdentity,
  });

return terminal({ status: "resolved" as const, resolution });

OAuthState.start returns flowCookieSameSite to direct server-side callers. A callback that has the server-owned provider can instead yield oauthFlowCookieSameSite(provider), which reruns exact provider validation and returns only "Lax" or "None". The browser start response intentionally omits this internal policy with the other flow bindings; never derive it from callback input or a free-form parameter.

Clear the flow cookie after every terminal callback outcome, including provider errors, invalid state, invalid ID tokens, linking rejection, and success. Merge clearFlowCookie with any session cookie as a distinct Set-Cookie field; do not comma-join it. Cookie identity is the fixed name, host-only scope, and / path; SameSite controls whether the browser carries and accepts the clearing response in the callback context. Query callbacks use Lax, while validated response_mode=form_post callbacks use None; always obtain that value from oauthFlowCookieSameSite(provider). One cookie slot is maintained, so the latest start wins. A valid flow is consumed before the external token exchange; provider or transport failure requires restarting login.

For OAuth-only providers, fetch their user endpoint and use the matching normalizer rather than treating unverified JSON as OIDC claims. Always key accounts by (providerId, providerAccountId), not email.

ResolutionResult
Known provider accountSign in even when this callback has no email
Previously unlinked provider accountReturn an explicit same-owner relink decision with its generation snapshot
New account with policy-trusted verified emailCreate the allowed user, identity, and provider account
Trusted email already belongs to another userReturn require-explicit-linking; never merge silently
Missing or untrusted emailCreate a bare user only when policy allows it

OAuthIdentityBridgePolicy controls JIT creation, verified-email trust, and local email identity creation. When resolveOAuthIdentityBridge returns require-explicit-linking, fail closed: there is no maintained link-confirmation HTTP endpoint or createOAuthClient().linkConfirmation method.

Provide makeDrizzleSqliteOAuthAccountStore, makeDrizzleD1OAuthAccountStore, or makeDrizzlePostgresOAuthAccountStore for durable account CRUD. Account linking is a pure decision: a historical tuple returns relink-existing-user with its stored owner, stable OAuthAccountId, updatedAt, and concrete unlinkedAt. After application policy authorizes reactivation, derive relinkedAt with nextOAuthAccountTransitionAt({ now, expectedUpdatedAt, expectedUnlinkedAt }), then call OAuthAccountStore.relink with the same expected timestamps. The helper validates max(now, expectedUpdatedAt + 1, expectedUnlinkedAt + 1) and fails instead of overflowing the safe timestamp range. Derive unlink timestamps the same way from now and the active account's updatedAt. New unlink and relink transitions must be strictly later than the current generation; equality is rejected. None means the generation changed; do not reuse the stale snapshot or insert a replacement. Re-run account resolution and handle the new active or historical generation explicitly. A different user can never claim the reserved tuple.

Explicit linking is not a browser primitive

Do not send userId, verified provider identity, or a confirmation secret in a generic public request body. The remaining low-level OAuthLinkConfirmation domain primitive is server-only, app-owned, legacy composition. Start, inspect, and confirm are proof-only. Confirmation can return a same-owner historical account snapshot; the caller then performs the generation-bound relink described above and treats a CAS miss as stale. This low-level manual-confirmation path does not atomically insert a new account; it is separate from policy-approved first-time JIT creation through the atomic identity bridge below. A future maintained manual-link design requires an authenticated session-bound, provider-verified pending link and an atomic OAuthLinkCommit that consumes the pending link and writes a new account together.

Use makeDrizzleSqliteOAuthIdentityBridgeStore, makeDrizzleD1OAuthIdentityBridgeStore, or makeDrizzlePostgresOAuthIdentityBridgeStore to atomically create the user, optional verified email identity, and first OAuth account. Provider-account races have one winner and identity collisions leave no partial user or account. Relinking and manual link confirmation remain separate explicit generation-bound CAS concerns and are not performed by the creation bridge. On success, pass trusted server-produced oauthEvidence({ providerId, providerAccountId, verifiedAt }) through AuthFlow; it derives local aal1 and canonical oauth. If MFA or approval is required, return that continuation instead. See Sessions and Security Policies.

The account store's unlink CAS does not make the wider authorize-then-unlink last-account policy atomic. Concurrent account changes can still invalidate a prior policy decision; applications requiring that invariant must implement a stronger combined policy-and-mutation transaction.

Provider access, refresh, and ID tokens are not session tokens. Discard them unless the app calls the provider later. If persisted, use OAuthProviderTokenLifecycle with OAuthProviderTokenVault: it encrypts token ciphertext before storage. Keep its encryption key separate, restrict decrypt access, rotate keys deliberately, and never log tokens.

Test and ship

  1. Test unknown providers, denied consent, missing/replayed/expired state, PKCE mismatch, nonce/issuer/audience/signature failure, and an exact redirect-URI mismatch.
  2. Test existing provider-account sign-in without email, email-less JIT creation, trusted verified-email creation, ignored unverified/untrusted email, fail-closed email collisions, cross-user takeover denial, MFA continuation, session cookie commitment, and safe returnTo handling.
  3. Provide AuthRateLimitStandardLive to maintained OAuth operation Layers and a durable limiter in production. The maintained start route limits trusted IP plus HMAC provider subject; callbacks remain app-owned and need equivalent controls. Test 429 Retry-After, redact secrets, use HTTPS and HttpOnly, Secure, appropriate SameSite cookies.
  4. Test the production challenge/account stores under concurrent callbacks and encrypt any retained provider tokens.

See HTTP Operations, Browser Client, and the Production Checklist.

On this page