Add Google sign-in

You need: the auth capability composed, a Google account with access to Google Cloud Console, and each environment’s public origin decided.

Time: about fifteen minutes, most of it in somebody else’s console.

Why this part is manual

Apple is the harder sibling. Auth is the capability, and what you end up holding is a credential.

Google OAuth credentials are minted in Google Cloud Console by a human with access to your Google account. There is no API to provision an OAuth client on your behalf, so nothing can do this for you.

The rest of the flow is config.

One Google Cloud project covers every environment. You register a separate redirect URI per environment below.

Under APIs & Services → OAuth consent screen:

  • User type: External
  • Scopes: email, profile, openid. Nothing more — Pithy only needs identity, and a scope you do not need is a scope on your consent screen scaring people off
  • Fill in the app name, support email and developer contact

While the app is in Testing, only test users you list can sign in. Publish it when you are ready for real users.

2. Create the OAuth client

APIs & Services → Credentials → Create credentials → OAuth client ID → Web application.

Register the redirect URIs from the next step, then create. You get a client id and a client secret.

3. The exact redirect URI

This is where most of the time gets lost, so it is worth getting right the first time.

The redirect URI is always:

<baseURL><basePath>/callback/google

With the default base path, that is /auth/callback/google. Register one per environment, each on that environment’s own origin:

EnvironmentRedirect URI
devhttp://localhost:8787/auth/callback/google
staginghttps://staging.example.com/auth/callback/google
productionhttps://example.com/auth/callback/google

Use your actual local port for dev — 8787 is the default rather than a guarantee.

Two rules. The path is your base path plus /callback/google, so a custom base path changes it. And the host must equal that environment’s origin exactly — a mismatch is the most common cause of a redirect error, and the error message is not specific about which half is wrong.

Feature-branch previews will not work, and this is why

Each environment needs its own registered URI on its exact host. Google only accepts a URL you have registered.

A branch deployed to an ephemeral preview URL is a host Google has never seen, so Google sign-in there fails — and it fails in a way that looks like your configuration is broken rather than like a URL is unregistered.

To exercise Google on a branch, either register that deployment’s own callback and point that deployment’s origin at the same host, or run the flow against localhost or your registered staging environment instead.

Magic link and one-time code have no redirect URI at all. They work on any URL, preview or not. Only the OAuth providers need a registered callback, which is a good reason to do most preview testing with them.

4. Store the credentials

Both halves travel as one typed JSON secret. The client id never splits off into config:

pithy secrets create auth-google-credentials

Paste {"clientId":"…","clientSecret":"…"} when prompted, or pipe it. The value never comes from a flag.

Then enable the provider in config — with no credential values in it:

auth({
  google: { enabled: true },
}),

The package reads the whole credential from the store at request time and wires it into the provider.

Each environment needs its own secret. The same Google project can back all of them, but the secret is per environment like every other.

5. Mobile, and the thing that surprises people

You do not register a custom-scheme or deep-link URI in Google Console. Google only ever redirects to your Worker’s callback.

The Worker, not Google, is what hands control back to the app. The mobile app passes its own deep link as the sign-in callback, the Worker completes the OAuth exchange, and then it redirects to that deep link.

For that to be allowed, the scheme has to be listed in your trusted origins — it is a prefix match:

auth({
  trustedOrigins: ["myapp://", "https://app.example.com"],
}),

The native id-token flow

For iOS or Android using Google’s own SDK rather than the web redirect, register the platform-native client ids in Console too, and pass all of them as an array:

auth({
  google: { clientId: ["<web-id>", "<ios-id>", "<android-id>"] },
}),

Account linking is automatic, and safely so

Somebody who signed up with a magic link and later signs in with Google is linked automatically when the verified emails match.

Google is configured as a trusted provider, and the local email is already verified — the magic link proved it — so the two merge into one account rather than colliding.

Leave the local-verification requirement on. It is the secure default, and it blocks a real attack: an attacker pre-registers an unverified row for a victim’s address and waits for the victim’s Google sign-in to link into it.

When the credential will not read

An enabled provider whose secret is missing, or whose stored value no longer matches its schema, costs that provider and nothing else. Magic link, one-time code and every other provider keep signing people in.

A Google sign-in attempt then answers 503 with auth/provider_unavailable, naming the provider — rather than the 404 a provider nobody enabled gets. Those are different facts and never share an answer: one means nobody enabled this, the other means this is broken right now, and somebody who signs in with Google every day needs to be able to tell them apart.

The attempt is recorded in the audit trail, which is where an operator learns a sign-in method is down.

Fix it by provisioning the secret for that environment, or by turning the provider off in config.

Checklist

ESC