Token Encryption
Tokens stored in auth_sessions are encrypted with AES-256-GCM before being written to the database.
| Property | Value |
|---|---|
| Algorithm | AES-256-GCM |
| IV size | 12 bytes (GCM standard; crypto.randomBytes(12)) |
| Auth tag size | 16 bytes |
| IV per operation | Yes — new random IV for every encryption |
Previous documentation incorrectly stated 16-byte IVs. The actual implementation in
lib/auth/server/crypto.tsuses 12 bytes, which is the standard for AES-GCM.
Key Derivation
Preferred path: TOKEN_ENCRYPTION_KEY env var (64+ hex chars) → padded/sliced to exactly 64 hex chars → Buffer.from(hex, 'hex') → 32-byte key.
Insecure fallback (no TOKEN_ENCRYPTION_KEY set): SHA-256 of the string 'GITHUB_CLIENT_SECRET:GITHUB_APP_ID:GITHUB_CLIENT_ID'. This is deterministic and weak. Always set TOKEN_ENCRYPTION_KEY in production.
Stored Fields
For access tokens: github_token_encrypted (ciphertext hex), github_token_iv (12-byte IV as hex), github_token_tag (16-byte auth tag as hex).
Same pattern for refresh tokens: github_refresh_token_encrypted, github_refresh_token_iv, github_refresh_token_tag.
Decryption
createDecipheriv('aes-256-gcm', key, ivBuffer)
.setAuthTag(tagBuffer)
.update(encrypted, 'hex', 'utf8') + .final()If the auth tag check fails, decryption throws — the record is considered corrupt.
Session Security
- Session ID:
crypto.randomBytes(32).toString('hex')— 256 bits of entropy, stored as 64-char hex. - Storage:
auth_sessionstable. Only the session ID goes in the cookie; all session data is in the DB. - Cookie:
gh_session,httpOnly: true,secure: true(production),sameSite: 'lax'. - Expiry: 24 hours from creation. Expired sessions are deleted on the next read attempt (via
deleteExpiredSessions()called on login).
OAuth State and CSRF
State tokens are signed JWTs (HS256 via jose).
Key: AUTH_SESSION_SECRET env. Insecure fallback: SHA-256 of 'GITHUB_CLIENT_SECRET:GITHUB_APP_ID'. Always set AUTH_SESSION_SECRET in production.
OAuth flow state
{ "type": "oauth", "csrf": "<32-byte base64url>", "mode": "...", "returnTo": "..." }Expiry: 10 minutes.
Install flow state
{ "type": "install", "csrf": "<32-byte base64url>", "returnTo": "...", "sessionId": "..." }Expiry: 10 minutes.
CSRF verification
The CSRF value is stored in two places: inside the signed JWT, and in an httpOnly same-named cookie. On callback, both are compared with ===. They must match exactly.
The type field prevents cross-flow attacks — an OAuth state JWT cannot be accepted by the install callback and vice versa.
Webhook Signature Verification
- Algorithm: HMAC-SHA256
- Header:
X-Hub-Signature-256: sha256=<hex-digest> - Comparison:
timingSafeEqual(Buffer.from(expected), Buffer.from(actual))— constant-time to prevent timing attacks.
If GITHUB_WEBHOOK_SECRET is not set: the handler logs a console.warn and accepts all requests. Never deploy without this secret.
GraphQL Proxy Allowlist
The GitHub GraphQL proxy (lib/github/graphqlProxy.ts) only accepts a fixed set of named operations:
operationNamemust be present inUSER_GITHUB_GRAPHQL_OPERATIONS(8 operations) — 403 otherwise.- The client-supplied query string is ignored. The server substitutes its own copy from
OPERATION_QUERY_FALLBACKS.
This prevents arbitrary field enumeration, introspection abuse, and query injection through the proxy endpoint. Server-side ingest queries bypass this entirely — they call lib/github/fetch-graphql.ts directly.
Known Gaps
These are existing weaknesses, documented here so they are not overlooked:
| Gap | Details |
|---|---|
| No RLS | All Supabase tables are accessed via the service role key. No row-level security policies. |
| Env validation skipped in prod | skipValidation: true in production. Bad env vars fail at runtime, not build time. |
AUTH_SESSION_SECRET fallback | Insecure deterministic fallback if env var is unset. |
TOKEN_ENCRYPTION_KEY fallback | Insecure deterministic fallback if env var is unset. |
| Debug routes unauthenticated (dev) | In non-production environments, debug routes are open with no authentication. |
| Token refresh skipped when null | If githubAccessTokenExpiresAt is null, the refresh check is skipped entirely. |
| No HTTPS enforcement at app layer | Relies on Vercel to terminate TLS. Application code does not redirect HTTP to HTTPS. |
| Session GC | Expired sessions are only purged when deleteExpiredSessions() is called at login — no background job. |
Audit Checklist
Before deploying to production:
-
TOKEN_ENCRYPTION_KEYis set to a 64+ char hex string -
AUTH_SESSION_SECRETis set to a 32+ char random string -
GITHUB_WEBHOOK_SECRETis set and matches the GitHub App configuration -
ENABLE_DEBUG_ROUTESis not set (or explicitlyfalse) unless debug access is intentional -
NEXT_PUBLIC_APP_URLexactly matches the GitHub App OAuth callback URL - All required env vars are present in the Vercel dashboard (no silent runtime failures)
- Supabase service role key is not exposed to the client (it is server-only in
supabaseEnv)