Authentication
Sign-in uses GitHub App user-to-server OAuth. The OAuth scopes (read:org user:email) are fixed — they come from the App configuration, not the client request.
The OAuth flow
State and CSRF
The state parameter is a signed JWT (HS256, keyed by AUTH_SESSION_SECRET):
{
"type": "oauth",
"csrf": "<32-byte-random>",
"mode": "web",
"returnTo": "/dashboard"
}The CSRF token is stored in both the JWT and as an httpOnly cookie (gh_auth_csrf). On callback, both are compared. They must match exactly.
The type field prevents cross-flow attacks — an OAuth state JWT can’t be accepted by the install callback.
Mobile mode
When mode === 'mobile' in the state JWT, the callback returns JSON instead of redirecting:
{
"sessionToken": "<64-char-hex>",
"session": { "id": "...", "user": { ... }, "installationIds": [], "expiresAt": "..." }
}The client is responsible for storing the token and sending it as Authorization: Bearer <token>. The browser flow (mode === 'web') sets the gh_session cookie instead.
Install relay
If the state JWT has type === 'install', the OAuth callback immediately redirects to /api/install/callback — the sign-in and install flows are chained transparently.
Token encryption
GitHub tokens are never stored in plaintext. Before hitting the database, they pass through lib/auth/server/crypto.ts:
| Property | Value |
|---|---|
| Algorithm | AES-256-GCM |
| IV | 12 random bytes |
| Key | TOKEN_ENCRYPTION_KEY (64+ hex chars → 32-byte key) |
| Key fallback | SHA-256 of GITHUB_CLIENT_SECRET:GITHUB_APP_ID:GITHUB_CLIENT_ID (deterministic, weak) |
Each token occupies three columns in auth_sessions:
github_token_encrypted— ciphertext (hex)github_token_iv— 12-byte IV (hex)github_token_tag— 16-byte GCM auth tag (hex)
A CHECK constraint enforces tokens_encrypted = true — unencrypted tokens are rejected at the database level.
Decryption happens only on session read, server-side. The plaintext token lives exclusively in process memory for the duration of the request. It is never returned in any API response (the SessionView type strips githubToken).
Session lifecycle
| Property | Value |
|---|---|
| Session ID | 64-char hex (crypto.randomBytes(32)) |
| Cookie name | gh_session |
| Cookie flags | httpOnly, Secure, SameSite=Lax |
| Session TTL | 24 hours (AUTH.SESSION_TTL_MS) |
| Storage | auth_sessions table |
Token refresh
If the access token is within 5 minutes of expiry (AUTH.TOKEN_REFRESH_BUFFER_MS), refreshAndUpdateSession() exchanges the refresh token for a new access token. The updated encrypted token is written back to auth_sessions transparently.
If githubAccessTokenExpiresAt is null, the refresh check is skipped. Some older GitHub App tokens don’t expire.
Session teardown
POST /api/auth/logout deletes the row from auth_sessions and clears all three cookies (gh_session, gh_auth_csrf, gh_auth_install_csrf). Expired sessions are purged on each sign-in via deleteExpiredSessions() — a fire-and-forget call inside the OAuth callback. If sign-ins are infrequent, expired rows accumulate.
State JWTs
| JWT Type | Fields | Expiry | When |
|---|---|---|---|
| OAuth state | { type: 'oauth', csrf, mode, returnTo } | 10 min | GET /api/auth/start |
| Install state | { type: 'install', csrf, returnTo, sessionId } | 10 min | GET /api/install/start |
The install JWT embeds sessionId so the callback can locate the session even if the gh_session cookie is unavailable (cross-device flow). All JWTs use HS256 via jose and are keyed by authEnv.sessionSecret (fallback: SHA-256 of GITHUB_CLIENT_SECRET:GITHUB_APP_ID).
Always set
AUTH_SESSION_SECRETin production. The fallback is deterministic and weak — anyone who knows yourGITHUB_CLIENT_SECRETandGITHUB_APP_IDcan forge state JWTs.