Authentication in this project involves two separate but related flows:
- OAuth sign-in — you log in with your GitHub account. Creates a web session that lasts 24 hours.
- GitHub App installation — the app gets installed on an organization. This gives the server permission to access org data using installation tokens.
Both use signed state tokens and CSRF cookies to prevent attacks. The detailed API route references (Auth Routes, Install Routes, Webhook) cover every endpoint in depth.
How the two flows work together
┌─────────────────────────────────────────────────────────────────┐
│ Authentication Flows │
├──────────────────────────┬──────────────────────────────────────┤
│ OAuth Sign-in │ GitHub App Install │
├──────────────────────────┼──────────────────────────────────────┤
│ 1. /api/auth/start │ 1. /api/install/start │
│ → GitHub OAuth page │ → GitHub install page │
│ 2. User authorizes │ 2. User installs app on org │
│ 3. /api/auth (callback) │ 3. /api/install/callback │
│ → Exchange code │ → Validate installation │
│ → Fetch profile │ → Update session installationIds │
│ → Upsert user/orgs │ 4. Webhook updates cache │
│ → Create session │ │
│ 4. gh_session cookie set │ │
│ 5. Redirect to returnTo │ │
└──────────────────────────┴──────────────────────────────────────┘You sign in first, then install the app on one or more organizations. The session keeps track of which installations you’ve done.
How auth works at the request level
Every protected route calls getRequestSession() (lib/auth/request-session.ts). Here’s what that does:
- Check for
Authorization: Bearer <sessionId>header (mobile apps, API clients) - Fall back to
gh_sessionhttpOnly cookie (web browser) - Look up the session ID in Supabase
auth_sessionstable - Join
users,organization_memberships, andorganizationsto build the full picture - Decrypt the GitHub OAuth token (AES-256-GCM) in memory
- Return
nullif session doesn’t exist or is expired (expired rows are auto-deleted)
The session object that comes back contains the decrypted access token, user profile, org memberships, and installation IDs. See the Security Model for details on cookies, encryption, and CSRF.
State tokens and CSRF
Both flows (OAuth and install) use the same pattern to prevent CSRF attacks:
- Server generates a random CSRF value and sticks it in an httpOnly cookie
- Server embeds the same CSRF value in a signed HS256 JWT (the “state” token)
- Browser redirects through GitHub — the state token comes along for the ride
- On the callback, server verifies the JWT signature, expiry, and that the CSRF values match
See Security Model for the full breakdown.
Backend architecture
Key files
| File | What it does | Deep dive |
|---|---|---|
app/api/auth/start/route.ts | Start OAuth flow | Auth routes |
app/api/auth/route.ts | OAuth callback — exchange code, create session | Auth routes |
app/api/auth/session/route.ts | Read current session, refresh if needed | Auth routes |
app/api/auth/logout/route.ts | Destroy session | Auth routes |
app/api/install/start/route.ts | Start GitHub App install | Install routes |
app/api/install/callback/route.ts | Install callback — merge installation ID | Install routes |
app/api/install/status/route.ts | Get installation summaries | Install routes |
app/api/install/complete/route.ts | Manual install completion | Install routes |
app/api/install/webhook/route.ts | GitHub webhook handler | Webhook |
lib/auth/server/session.ts | Session CRUD with joins | Security |
lib/auth/server/oauth.ts | Token exchange, profile fetch | — |
lib/auth/server/installation-service.ts | Installation cache + sync | — |
lib/auth/server/user-service.ts | User/org/membership upserts | — |
lib/auth/server/crypto.ts | AES-256-GCM encrypt/decrypt | Security |
lib/auth/server/webhook.ts | HMAC signature verification | Webhook |
lib/auth/state.ts | State JWT creation and verification | Security |
lib/auth/request-session.ts | Session lookup from request | Security |
lib/auth/server/cookies.ts | Cookie constants | — |
Session lifecycle
Sign in → Upsert user + orgs + memberships
↓
Create session in auth_sessions
↓
Set gh_session cookie
│
├── [24 hours pass] → Auto-deleted on read
│
├── User signs out → Deleted immediately
│
├── Token expires → Refreshed via refresh_token
│
└── User returns → Read from cookie, decrypted,
checked against DB, returnedWhat’s stored in a session
The auth_sessions table keeps it lean:
- Session ID (random 64-char hex)
- User ID (FK to
users) - Encrypted GitHub token (AES-256-GCM with IV and auth tag)
- Encrypted refresh token (same pattern)
- Token metadata (type, scope, expiry)
- Created/expiry timestamps
User data stays in the users table. The session doesn’t embed it — it’s resolved via joins when you read the session.
Installations are stored in the session_installations join table (not an array column on auth_sessions).
What the client sees
The GET /api/auth/session response intentionally leaves out the OAuth token:
{
"authenticated": true,
"session": {
"id": "sess_abc123",
"user": {
"id": 12345,
"login": "octocat",
"name": "Octocat",
"avatarUrl": "...",
"organizations": [
{
"id": 67890,
"login": "my-org",
"name": "My Org",
"avatarUrl": "...",
"viewerCanAdminister": true
}
]
},
"installationIds": [456],
"expiresAt": "2026-05-26T12:00:00.000Z"
}
}The installationIds field is resolved from the session_installations join table.
Token encryption
GitHub tokens are encrypted at rest using AES-256-GCM:
- Key:
TOKEN_ENCRYPTION_KEYenv var (or a derived fallback) - Algorithm: AES-256-GCM with a random 16-byte IV per encryption
- Storage: ciphertext, IV, and auth tag in separate columns
- Scope: both access tokens and refresh tokens
- In-memory only: decrypted only when a session is read, server-side
Implementation lives in lib/auth/server/crypto.ts. See Security Model for details.
Frontend auth components
| Component / Hook | What it does |
|---|---|
hooks/use-auth-session.ts | React Query hook — fetches /api/auth/session on mount |
stores/auth-store.ts | Zustand store — global session state |
components/auth/ProtectedRoute.tsx | Route guard — redirects unauthenticated users |
components/auth/AuthErrorBanner.tsx | Shows auth/install errors from URL params |
components/layout/Header.tsx | Global header — sign-in, org switcher, user menu |
hooks/queries/use-organizations-query.ts | Org list with installation status |
Troubleshooting
Common errors
| Error | Likely cause |
|---|---|
state mismatch | CSRF cookie expired or tampered — retry |
redirect_uri mismatch | GitHub App callback URL doesn’t match registration |
bad_verification_code | OAuth code expired — retry sign-in |
session not found | Session expired or deleted — sign in again |
401 on GraphQL | OAuth token expired or session missing — re-authenticate |
installation not found | GitHub App not installed on the org — install it |
Debugging
- Browser cookies to check:
gh_session,gh_auth_csrf,gh_install_csrf - Supabase tables to inspect:
auth_sessions,users,organizations,organization_memberships - Auth errors appear as URL params:
?authError=...or?installError=... - The
AuthErrorBannercomponent parses and displays these automatically - Webhook events are logged with
[webhook]prefix in server logs
Security notes
- State tokens are signed with HS256, verified server-side
- CSRF checks required on all callback endpoints
- Session lookup and mutation happen server-side only
- OAuth token is never returned to the client
- GitHub tokens encrypted at rest with AES-256-GCM
- Webhook signatures verified with HMAC-SHA256
Related
- Auth API routes — complete endpoint reference
- Install API routes — all install endpoints
- Webhook API — GitHub App webhook handler
- Organization Routes — list, detail, mass invite
- Security Model — cookies, CSRF, state tokens, encryption
- Database Schema — all tables and relationships