This doc covers the security architecture — how we protect tokens, prevent CSRF, manage sessions, and verify webhooks.
Cookies
gh_session
The main session cookie. httpOnly, secure, sameSite=lax, path=/, maxAge=session expiry (~24h).
Set by GET /api/auth, cleared by POST /api/auth/logout.
Contains only the session ID (64-char hex). No JWT, no user data, no tokens.
sameSite=lax means it’s sent on top-level navigations (like GitHub redirects) but not on cross-site subrequests.
gh_auth_csrf
CSRF cookie for the OAuth flow. httpOnly, secure, sameSite=none, path=/, maxAge=600 (10 min).
32-byte base64url random value.
Set by GET /api/auth/start, cleared by GET /api/auth.
gh_install_csrf
CSRF cookie for the installation flow. httpOnly, secure, sameSite=none, path=/, maxAge=600 (10 min).
32-byte base64url random value.
Set by GET /api/install/start, cleared by GET /api/install/callback.
Both CSRF cookies use sameSite=none because OAuth redirect flows cross origins.
State tokens
Signed HS256 JWTs via jose (lib/auth/state.ts). Keyed by authEnv.sessionSecret (falls back to GITHUB_CLIENT_SECRET:GITHUB_APP_ID hash).
OAuth state
| Field | Description |
|---|---|
type: 'oauth' | Discriminator |
csrf | Matches gh_auth_csrf cookie |
mode | 'web' or 'mobile' |
returnTo | Redirect target |
Created by: GET /api/auth/start. Verified by: GET /api/auth. 10-min expiry.
Install state
| Field | Description |
|---|---|
type: 'install' | Discriminator |
csrf | Matches gh_install_csrf cookie |
returnTo | Redirect target |
sessionId | Session ID at creation time |
Created by: GET /api/install/start. Verified by: GET /api/install/callback. 10-min expiry.
Security properties
| Property | Mechanism |
|---|---|
| Integrity | HS256 signature prevents tampering |
| Freshness | 10-min exp prevents replay |
| Non-replayable | Each flow generates new random CSRF |
| Cookie binding | CSRF value in both JWT and httpOnly cookie — attacker can’t read cookie to forge JWT, can’t read JWT to forge cookie |
| Type safety | type field prevents cross-flow attacks |
Token encryption
GitHub OAuth tokens are encrypted at rest using AES-256-GCM (lib/auth/server/crypto.ts).
Encryption process
plaintext (GitHub token)
↓
randomBytes(16) → IV
↓
createCipheriv('aes-256-gcm', key, iv)
↓
ciphertext + authTag (16 bytes)
↓
Store: { encrypted: ciphertext, iv, tag: authTag }Key derivation
TOKEN_ENCRYPTION_KEYenv var (preferred, 64+ hex chars)- Fallback:
GITHUB_CLIENT_SECRET:GITHUB_APP_ID:GITHUB_CLIENT_IDconcatenation
The key is hashed with SHA-256 to produce the 32-byte AES key.
Storage
| Column | Content |
|---|---|
github_token_encrypted | AES-256-GCM ciphertext |
github_token_iv | 16-byte initialization vector |
github_token_tag | 16-byte authentication tag |
Same pattern for refresh tokens (github_refresh_token_* columns).
Decryption
ciphertext + iv + tag
↓
createDecipheriv('aes-256-gcm', key, iv)
decipher.setAuthTag(tag)
↓
plaintext (GitHub token)Decryption happens only server-side when a session is read. The decrypted token never leaves the server.
Security properties
| Property | Mechanism |
|---|---|
| Confidentiality | AES-256 encryption |
| Integrity | GCM authentication tag detects tampering |
| Unique IV | Random 16-byte IV per encryption prevents pattern analysis |
| Key isolation | Separate env var, not stored in database |
Session management
ID generation
lib/auth/server/session.ts:
function randomSessionId(): string {
return crypto.randomBytes(32).toString('hex');
}64-char hex (256 bits entropy).
Storage
Supabase auth_sessions table with normalized foreign keys:
| Column | Purpose |
|---|---|
id (PK) | Session ID |
user_id (FK) | References users(id) |
github_token_encrypted | Encrypted OAuth token |
github_token_iv | AES-GCM IV |
github_token_tag | AES-GCM auth tag |
github_refresh_token_* | Encrypted refresh token (same pattern) |
expires_at | Session expiry timestamp |
No embedded user data. Profile, organizations, and memberships are resolved via joins when the session is read.
Installations are stored in the session_installations join table (not an array column).
Expiry
24-hour TTL by default (session.ts), or the OAuth token’s expiresAt if shorter. Expired sessions are auto-deleted on read.
Token exposure
The OAuth token is stored in the database but never included in API responses. The SessionView type (types/auth/session.ts) explicitly omits githubToken.
Request authentication
lib/auth/request-session.ts:
Authorization: Bearer <sessionId>→getSession(x)- Fallback:
gh_sessioncookie →getSession(cookie.value) - Both fail → null
getSession() (lib/auth/server/session.ts):
- Query Supabase
auth_sessionsby ID - Check expiry (delete if expired)
- Join
usersviauser_id - Join
organization_memberships+organizationsfor org list - Decrypt GitHub token
- Return
AuthSessionor null
Webhook security
lib/auth/server/webhook.ts verifies HMAC-SHA256 signatures on GitHub webhook payloads.
Verification
expected = "sha256=" + HMAC-SHA256(GITHUB_WEBHOOK_SECRET, requestBody)
actual = X-Hub-Signature-256 header
timingSafeEqual(expected, actual) → booleanFallback
If GITHUB_WEBHOOK_SECRET isn’t set, verification is skipped with a console warning. Not recommended for production.
Error responses
All protected routes return: { "error": "..." }. Produced by lib/auth/response.ts:
export function jsonError(message: string, status = 400) {
return NextResponse.json({ error: message }, { status });
}Messages are user-facing. Sensitive details are logged server-side only via console.error.
Attack vector mitigations
| Vector | Mitigation |
|---|---|
| OAuth CSRF | state JWT csrf field must match httpOnly gh_auth_csrf cookie |
| Install CSRF | Same pattern with gh_install_csrf |
| Session hijacking | httpOnly + secure + 24h TTL + admin check on sensitive ops |
| Replay (OAuth code) | GitHub codes are single-use. JWT 10-min window limits exposure. |
| JWT forgery | HS256 signed with AUTH_SESSION_SECRET. jose.verify throws on invalid. |
| Info leakage | SessionView strips githubToken from API responses |
| Token theft | AES-256-GCM encryption at rest. Key never in DB. |
| Webhook spoofing | HMAC-SHA256 signature verification with GITHUB_WEBHOOK_SECRET |
| Arbitrary GraphQL | Operation allowlist checked before any API call |
Related
- Auth Routes — OAuth sign-in, session, logout endpoints
- Install Routes — GitHub App installation flow
- Webhook — GitHub App webhook handler
- Database Schema —
auth_sessions,users,organizationstables