This app uses GitHub App user-to-server OAuth — not a classic GitHub OAuth App. Authorization is handled via @octokit/app internally. Scopes passed to the authorize URL are ignored by GitHub; the effective permissions come from the App configuration.
OAuth flow
The session is created before installations are discovered. Listing and syncing installations (GET /user/installations) runs as a best-effort follow-up wrapped in try/catch: if GitHub or the database is briefly unavailable, the user is still signed in (with zero installations) instead of being bounced to /account?authError=auth_failed. Installations are then resolved lazily per organization — see installation.
Mobile mode
When mode === 'mobile' in the state JWT, the callback returns JSON instead of redirecting:
{
"sessionToken": "<64-char hex>",
"session": {
/* SessionView */
}
}Token encryption (lib/auth/server/crypto.ts)
Tokens are encrypted before storage. They are never stored in plaintext and never returned in API responses.
| Field | Value |
|---|---|
| Algorithm | AES-256-GCM |
| IV length | 12 bytes |
| Auth tag length | 16 bytes |
| Key source | TOKEN_ENCRYPTION_KEY env (64+ hex chars → 32-byte Buffer) |
| Key fallback | SHA-256 of 'GITHUB_CLIENT_SECRET:GITHUB_APP_ID:GITHUB_CLIENT_ID' |
Each token occupies three columns in auth_sessions:
github_token_encrypted,github_token_iv,github_token_taggithub_refresh_token_encrypted,github_refresh_token_iv,github_refresh_token_tag
Decryption happens only on session read server-side. Plaintext never reaches the database or any API response body.
Session read & token refresh
getRequestSession() accepts either:
Authorization: Bearer <sessionId>headergh_sessioncookie
Session lookup: query auth_sessions by ID → join users, org_memberships, organizations, session_installations → decrypt token.
Refresh logic:
- If
githubAccessTokenExpiresAtis NOT NULL and is within 5 minutes of expiry → callrefreshGitHubToken()→ encrypt new token → updateauth_sessions - If
githubAccessTokenExpiresAtIS NULL (GitHub App did not return expiry) → refresh is skipped entirely
SessionView returned by GET /api/auth/session:
{
id: string
user: {
id: string
login: string
name: string | null
avatarUrl: string
organizations: Array<{
id: string
login: string
name: string | null
avatarUrl: string
viewerCanAdminister: boolean
}>
}
installationIds: string[]
expiresAt: string
}githubToken is intentionally omitted from SessionView (types/auth/session.ts).
State JWTs
All state JWTs use HS256 via jose, keyed by authEnv.sessionSecret, with a 10-minute expiry.
| Type | Fields |
|---|---|
| OAuth | { type: 'oauth', csrf, mode: 'web'|'mobile', returnTo } |
| Install | { type: 'install', csrf, returnTo, sessionId } |
The install JWT embeds sessionId so the install callback can locate the session even if the gh_session cookie is absent (e.g. cross-device flow).
Cookies
| Cookie | sameSite | httpOnly | secure | maxAge | Content |
|---|---|---|---|---|---|
gh_session | lax | yes | yes (prod) | ~24 h | 64-char hex session ID |
gh_auth_csrf | none | yes | yes | 600 s | 32-byte base64url CSRF |
gh_auth_install_csrf | none | yes | yes | 600 s | 32-byte base64url CSRF |
gh_auth_csrf is deleted after a successful OAuth callback. gh_auth_install_csrf is deleted after a successful install callback.
Session cleanup
deleteExpiredSessions() is called fire-and-forget inside the OAuth callback:
deleteExpiredSessions().catch(() => {});It is not scheduled — it only runs when a user signs in. See background jobs for the implications of orphaned DB maintenance functions.