Add GitHub sign-in

You need: the auth capability composed, and a GitHub account or organization.

Time: ten minutes per environment, and the per-environment part is the wrinkle.

One app per environment, and that is forced

Google and Facebook are the same shape. What lands at the end is a credential.

A GitHub OAuth app allows exactly one callback URL. So unlike Google or Facebook, you cannot register three URLs on one app — you register a separate app per environment.

GitHub → Settings → Developer settings → OAuth Apps → New OAuth App. For an organization, use the organization’s settings instead.

  • Application name — whatever your users should see on the consent screen
  • Homepage URL — your app or site
  • Authorization callback URL — the exact URI below

On create you get a client id, and you generate a client secret on the same page.

The exact callback URL

<baseURL><basePath>/callback/github

With the default base path, /auth/callback/github.

EnvironmentCallback URL
devhttp://localhost:8787/auth/callback/github
staginghttps://staging.example.com/auth/callback/github
productionhttps://example.com/auth/callback/github

Use your actual local port for dev.

Anything else is rejected with the redirect_uri MUST match the registered callback URL for this application, which is at least a clear error.

Feature-branch previews

A preview URL is not the callback URL on any of your apps, so GitHub sign-in there fails.

And because a GitHub app allows only one callback URL, a per-branch app is the only way to exercise it on an ephemeral URL — which is usually not worth it. Run the flow against staging, or use magic link, which needs no callback at all.

Scope

Pithy requests user:email, which is what lets the sign-in read the primary address and its verified status from GitHub’s email API.

You do not configure scopes anywhere. It is set for you when GitHub is enabled.

Mobile

You do not register a deep link with GitHub. GitHub only ever redirects to your Worker’s callback.

The app passes its own deep link as the sign-in callback, the Worker completes the exchange and redirects there, and the scheme has to be listed in your trusted origins:

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

Store the credentials

Both halves travel as one typed JSON secret:

pithy secrets create auth-github-credentials

Then enable it, with no credential values in config:

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

Each environment’s app has its own credentials, so each environment has its own secret.

Account linking is stricter here, deliberately

GitHub is not a trusted provider, and that is a considered difference from Google and Apple.

The reason is specific: GitHub lets an account hold unverified addresses. So linking on the address alone would let somebody add an address they do not own to their GitHub account and sign in as its owner.

The sign-in’s primary emailWhat happens
Verified on GitHub, matching an existing userLinks into that account. No second account
Not verified on GitHubRefused. No link, and no new account either

That second row is the important one. It is refused rather than used to create a fresh account — so nobody can seed a row at an address they have not proven they own.

The way through is to verify the address on GitHub, or to sign in with a magic link to it first and then connect GitHub.

When the credential will not read

An enabled provider whose secret is missing or malformed costs that provider and nothing else. Magic link, one-time code and every other provider keep working.

A GitHub attempt answers 503 with auth/provider_unavailable, naming the provider — rather than the 404 a provider nobody enabled gets. The attempt is recorded in the audit trail.

Checklist

ESC