These four routes handle everything related to authentication. You’ll typically only interact with them through the UI, but the contracts are here if you need to build your own client.
GET /api/auth/start
File: app/api/auth/start/route.ts
Initiates the OAuth flow. Redirects to GitHub’s authorization page.
| Param | Type | Default | Description |
|---|---|---|---|
returnTo | string | / | Where to redirect after sign-in |
mode | "web" | "mobile" | "web" | "mobile" makes the callback return JSON instead of setting cookies |
What happens
- Generate CSRF:
randomBase64Url(32)→ 32 bytes, base64url - Create signed OAuth state JWT (HS256): payload is
{ type: "oauth", csrf, mode, returnTo }, expires in 10 minutes - Build GitHub auth URL:
https://github.com/login/oauth/authorize?client_id=<id>&redirect_uri=/api/auth&scope=read:org+user:email&state=<jwt>&allow_signup=false - Set
gh_auth_csrfcookie: httpOnly, secure, sameSite=none, path=/, maxAge=600 - 302 redirect to GitHub
State token payload
interface OAuthState {
type: 'oauth';
csrf: string;
mode: 'web' | 'mobile';
returnTo: string;
}Signed via lib/auth/state.ts (jose, HS256, keyed by authEnv.sessionSecret).
Cookie: gh_auth_csrf
httpOnly, secure, sameSite=none, path=/, maxAge=600. Random 32-byte base64url.
Edge cases
- Previous CSRF cookie gets overwritten each call. That’s fine — the old state JWT would have a mismatch anyway.
- If
AUTH_SESSION_SECRETis missing, falls back to a hash ofGITHUB_CLIENT_SECRET:GITHUB_APP_ID. - Very long
returnTovalues are embedded in the JWT (~8KB limit), but the browser URL might truncate on redirect.
GET /api/auth
File: app/api/auth/route.ts
The OAuth callback. GitHub redirects here after the user authorizes the app.
| Param | Required | Description |
|---|---|---|
code | yes | GitHub auth code (single-use, ~10 min expiry) |
state | yes | Signed JWT from /api/auth/start |
What happens
- Verify state JWT: check HS256 signature, expiry, read csrf/mode/returnTo
- If
state.type === "install": redirect to/api/install/callback(handles install callbacks that land here) - CSRF check:
gh_auth_csrfcookie must exist and matchstate.csrf. Fail → 403 - Exchange code:
POST https://github.com/login/oauth/access_tokenwithAccept: application/json. Gets backaccess_token,token_type,scope, optionallyexpires_in/refresh_token. Fail iferrorfield in response. - Encrypt the access token using AES-256-GCM (
lib/auth/server/crypto.ts): producesencrypted,iv,tag - Fetch viewer profile (
lib/auth/server/oauth.ts):octokit.graphqlViewerProfile query → id, login, name, avatar, orgs (paginated). Falls back to REST if GraphQL fails. - Discover installations (
lib/auth/server/installation-service.ts:listOrgInstallations): paginateGET /user/installations, filter toaccount_type === 'organization', return installation IDs. - Upsert normalized user data (
lib/auth/server/user-service.ts):upsertUser()— insert or updateuserstableupsertOrganizations()— insert or updateorganizationstableupsertMemberships()— insert or updateorganization_membershipstable
- Sync installations (
lib/auth/server/installation-service.ts:syncInstallations): upsert each org installation intogithub_installationstable. - Create session (
lib/auth/server/session.ts:createSession):- 64-char hex session ID via
crypto.randomBytes(32).toString('hex') - Insert into
auth_sessionswith user_id (FK), encrypted token - Insert session-installation links into
session_installationsjoin table - Expiry: 24 hours default
- 64-char hex session ID via
- Set
gh_sessioncookie (httpOnly, sameSite=lax, secure, maxAge=remaining seconds) + deletegh_auth_csrfcookie - 302 redirect to
returnTo
Response (web)
302 Redirect to returnTo with gh_session cookie set.
Response (mobile/JSON)
When mode=mobile:
{
"sessionToken": "sess_abc123...",
"session": {
"id": "sess_abc123...",
"user": {
"id": 12345,
"login": "octocat",
"name": "Octocat",
"avatarUrl": "https://avatars.githubusercontent.com/u/12345",
"organizations": [
{
"id": 67890,
"login": "my-org",
"name": "My Org",
"avatarUrl": "...",
"viewerCanAdminister": true
}
]
},
"installationIds": [456, 789],
"expiresAt": "2026-05-26T12:00:00.000Z"
}
}gh_auth_csrf is deleted in both modes.
Error responses
All web-mode errors redirect to returnTo with ?authError=<message>.
| Status | Condition | Message |
|---|---|---|
| 400 | Missing state | ”Something went wrong during sign in. Please try again.” |
| 400 | Invalid/expired state JWT | ”Your sign in session was invalid. Please try signing in again.” |
| 400 | Missing code | ”GitHub did not complete the sign in process. Please try again.” |
| 403 | CSRF mismatch | ”Your sign in session expired. Please try signing in again.” |
| 500 | Token exchange/profile failed | ”Could not complete sign in. GitHub may be temporarily unavailable.” |
Edge cases
- Expired state JWT (>10 min):
jwtVerifythrows → 400. Retry. - Reused auth code: GitHub returns error on exchange → 500.
- No org access: session created with empty
organizationsarray. Auth works, org features blocked. - No installations: session created with
installationIds: []. Must install app. - State cookie missing (privacy mode): CSRF check fails → 403.
GET /api/auth/session
File: app/api/auth/session/route.ts
Reads the current session. Session ID comes from (in order):
Authorization: Bearer <sessionId>(mobile/API)gh_sessioncookie (web)
Both are handled by getRequestSession() (lib/auth/request-session.ts) → getSession() → Supabase lookup with joins. Expired sessions are auto-deleted.
Token refresh
If githubAccessTokenExpiresAt is set and within 5 minutes of expiry:
- Calls
refreshGitHubToken()(lib/auth/server/token-refresh.ts) - If refreshed: encrypts new token, updates
auth_sessionsrow - If expired (no valid refresh token): returns 401
Response (authenticated)
{
"authenticated": true,
"session": {
"id": "sess_abc123...",
"user": {
"id": 12345,
"login": "octocat",
"name": "Octocat",
"avatarUrl": "https://avatars.githubusercontent.com/u/12345",
"organizations": [...]
},
"installationIds": [456, 789],
"expiresAt": "2026-05-26T12:00:00.000Z"
}
}The SessionView type (types/auth/session.ts) intentionally omits githubToken.
Response (unauthenticated) — 401
{ "authenticated": false, "session": null }Frontend consumption
useAuthSession() hook (hooks/use-auth-session.ts): React Query, staleTime=0, retry=false. Data synced into useAuthStore (Zustand).
Edge cases
- Session expires between check and render:
authenticated: false.ProtectedRoutecatches and redirects to/api/auth/start. - Supabase transient error:
getSession()returns null → 401. Handled byrefetchOnMount: 'always'+refetchOnReconnect: true. - Rapid page loads: React Query deduplicates within staleTime.
POST /api/auth/logout
File: app/api/auth/logout/route.ts
No body required. Session from gh_session cookie or Authorization header.
{ "ok": true }What happens
getRequestSession(request)→ read session- If it exists,
deleteSession(session.id)from Supabase - Delete cookies:
gh_session,gh_auth_csrf,gh_install_csrf
Frontend logout (Header.tsx)
POST /api/auth/logout- Toast “Signed out successfully”
setSession(null)in Zustand- Update + invalidate React Query
['auth-session'] - Remove
['install-status']query - Redirect to
/
Edge cases
- No session: delete is a no-op. Still clears cookies and returns
{ ok: true }. - Supabase delete fails: fire-and-forget. Cookies still cleared.
- Cookie clearing blocked: session ID already deleted from Supabase — cookies are inert.
Implementation architecture
Module map
| Module | File | Purpose |
|---|---|---|
| Crypto | lib/auth/server/crypto.ts | AES-256-GCM encrypt/decrypt |
| Cookies | lib/auth/server/cookies.ts | Cookie constants and defaults |
| OAuth | lib/auth/server/oauth.ts | Code exchange, profile fetch, installation listing |
| Session | lib/auth/server/session.ts | Create, read, update, delete with Supabase joins |
| Installation | lib/auth/server/installation-service.ts | Cache, sync, validate installations |
| User | lib/auth/server/user-service.ts | Upsert users, orgs, memberships |
| Token Refresh | lib/auth/server/token-refresh.ts | Refresh expired GitHub tokens |
| Webhook | lib/auth/server/webhook.ts | HMAC signature verification |
| State | lib/auth/state.ts | HS256 JWT state token create/verify |
| Request | lib/auth/request-session.ts | Read session from request (cookie or bearer) |
Session data flow
GitHub OAuth → exchangeOAuthCode()
↓
encrypt() token
↓
getViewerProfile() → orgs
↓
UserService.upsertUser()
UserService.upsertOrganizations()
UserService.upsertMemberships()
↓
InstallationService.syncInstallations()
↓
createSession() → auth_sessions table
↓
Set gh_session cookieRelated
- Install Routes — GitHub App installation flow
- Webhook — GitHub App webhook handler
- Organization Routes — org list and detail
- Security Model — cookies, CSRF, encryption
- Database Schema —
auth_sessions,users,organizationstables