GitHub OAuth
WIPGitHub Sign-in with PKCE-S256 and signed state. Default scopes read:user + user:email.
Setup
- Go to Settings -> Developer settings -> OAuth Apps in your GitHub account or organization.
- Click New OAuth App.
- Set Authorization callback URL to
https://app.example.com/auth/github/callback. - Copy the client ID and client secret.
import { authGithub } from '@gentleduck/auth/providers/oauth/github'
export const auth = new AuthEngine({
// ...
providers: [
authGithub({
clientId: process.env.GITHUB_CLIENT_ID!,
clientSecret: process.env.GITHUB_CLIENT_SECRET!,
redirectUri: 'https://app.example.com/auth/github/callback',
stateSigningSecret: process.env.OAUTH_STATE_SECRET!,
scopes: ['read:user', 'user:email'], // default
onSignIn: async ({ profile }) => {
const primaryEmail = profile.emails.find((e) => e.primary)?.email
let identity = await auth.identities.findByProviderSub({
providerId: 'github',
sub: String(profile.id),
})
if (!identity && primaryEmail) {
identity = await auth.identities.findByEmail(primaryEmail)
}
if (!identity) {
identity = await auth.identities.create({
email: primaryEmail ?? `${profile.login}@users.noreply.github.com`,
emailVerified: !!primaryEmail,
profile: {
displayName: profile.name ?? profile.login,
avatarUrl: profile.avatar_url,
githubLogin: profile.login,
},
})
}
await auth.identities.linkProvider(identity.id, {
providerId: 'github',
sub: String(profile.id),
email: primaryEmail,
})
return { identityId: identity.id }
},
}),
],
})
Callback route
Same shape as every other OAuth provider:
// Express
app.get('/auth/github/callback', authMountSignIn(auth))
// Next.js - app/auth/github/callback/route.ts
export const GET = authNextSignIn(auth)
What the profile contains
GitHub's /user endpoint returns:
{
id: 12345, // numeric, stable
login: 'ada', // username
name: 'Ada Lovelace',
email: 'ada@example.com', // may be null if user hides it
avatar_url: 'https://avatars.githubusercontent.com/u/12345?v=4',
bio: '...',
company: '...',
location: '...',
// ... and many more
}
The provider also pulls the user's emails (with user:email scope)
from /user/emails so you get the primary verified email even if
they've hidden it from the public profile.
Restricting by org
For internal apps where only members of your GitHub org should sign in:
onSignIn: async ({ profile }) => {
const orgs = await fetch('https://api.github.com/user/orgs', {
headers: {
Authorization: `Bearer ${profile._accessToken}`,
'User-Agent': 'example-app',
},
}).then((r) => r.json())
if (!orgs.some((o) => o.login === 'example-org')) {
throw new AuthError('AUTH/PROVIDER_FAILED', {
reason: 'not-in-org',
})
}
// Add read:org to scopes so /user/orgs returns full list, not just public.
// ...
}
If you need this often, scope the provider with read:org and call
the API once per sign-in.
GitHub App vs OAuth App
duck-auth uses the OAuth App flow. If you have a GitHub App (which supports finer-grained permissions), you can still use the standard OAuth provider - the OAuth handshake is the same. You just lose the App-specific webhook integration.
Common errors
| Error from GitHub | duck-auth code | Cause |
|---|---|---|
redirect_uri_mismatch | AUTH/PROVIDER_FAILED | Callback URL doesn't match. |
bad_verification_code | AUTH/PROVIDER_FAILED | Code expired (10 minutes) or already used. |
access_denied | AUTH/PROVIDER_FAILED | User clicked Cancel on the consent screen. |