ADR-004: WorkOS AuthKit for Bugpup login
Status: Accepted 2026-07-25 Project: Bugpup
Context
Bugpup was using a hand-rolled GitHub OAuth flow with a custom JWT session secret. Pain points:
- Pinned to one OAuth provider (GitHub). Users without GitHub accounts couldn't sign in.
- JWT issuance/refund/rotation logic was our responsibility to maintain.
- Email verification, MFA, and session management needed to be built from scratch.
- The cookie format of the Next.js session and the Go API session diverged, forcing a dual-cookie scheme.
Decision
Adopt WorkOS AuthKit for both the dashboard (Next.js) and the API (Go).
- Hosted login UI — AuthKit handles the sign-in/sigin-up UI, email verification, MFA, and OAuth providers. We redirect to it.
- Single OAuth provider — GitHub remains the primary provider (existing user base), but additional providers (Google, Microsoft, email magic link) slot in without code changes.
- WorkOS session — the Next.js SDK
@workos-inc/authkit-nextjsseals the session in aniron-sessioncookie (wos-session). The Go API validates the access token (JWT) from the Authorization header, not the sealed cookie, because the Next.js and Go SDKs use incompatible cookie formats.
Architecture
Browser → Next.js (apps/web) → Go API (apps/api)
│ │
│ AuthKit middleware │ JWT middleware
│ reads sealed cookie │ validates Bearer token
│ extracts accessToken │ against WorkOS JWKS
│ forwards as Bearer │ fetches user via Mgmt API
│ │ upserts local user
Why forward the access token, not the cookie
The Next.js SDK seals the session cookie with iron-session (uses a different key derivation + envelope than the Go SDK's AES-256-GCM). Trying to re-implement iron-session decryption in Go was not worth the complexity. Forwarding the JWT access token bypasses the incompatibility entirely — the browser already has it inside the cookie.
JWT validation
- Read
Authorization: Bearer <jwt>from the request. - Parse the JWT unverified to read the
iss(issuer) claim. - Fetch the JWKS document via OIDC discovery (
<iss>/.well-known/openid-configuration→jwks_uri). - Verify the JWT signature against the JWKS.
- Use the
subclaim to fetch the user via the WorkOS Management API (GET /user_management/users/{sub}) with the API key. - Upsert the local user row keyed on
workos_id.
Why not /userinfo
The authkit domain's /oauth2/userinfo endpoint requires the access token to carry specific scopes (specifically openid profile email) that the Next.js SDK-managed sessions don't grant. The Management API uses the API key instead, which is reliable for both staging and production.
Why api.workos.com for both staging and production
We initially assumed sk_test_ keys should hit api.workos-test.com. WorkOS returns 404 for that host on the /sso/jwks and /user_management/users endpoints. Both endpoints live on api.workos.com regardless of sk_test_ / sk_live_ prefix. The sk_test_ prefix is a usage convention, not a host router.
Consequences
- Single source of truth for user identity (WorkOS). Local users table becomes a projection.
- Email verification, MFA, and session management are now WorkOS's problem.
- New OAuth providers can be added in the WorkOS dashboard without code changes.
- We pay WorkOS — but only for active users (no per-seat pricing).
- Cookie-format mismatch between Next.js and Go SDKs is no longer a problem (we use the access token).
GitHub repo access (separate from login)
Bugpup's "Import GitHub repo" flow needs a per-user GitHub access token to list repos and read package.json. This is separate from WorkOS login — a user can sign in with Google and still need to authorize GitHub.
We use a custom GitHub OAuth App (not a GitHub App, not WorkOS GitHub OAuth) so the code stays simple and the token surface is minimal. The flow:
Browser → /api/auth/github/connect → GitHub OAuth → /api/auth/github/callback
↓
exchange code → token
↓
POST /api/v1/profile/github
↓
store token on user
Why not WorkOS GitHub OAuth / WorkOS Pipes
- WorkOS GitHub OAuth with "Return GitHub OAuth tokens" enabled and a custom OAuth App credential set fails at
authentication.oauth_failedwithError fetching GitHub profilein our sandbox environment. Credentials verified valid independently. Logs show no permission/scope issue. - WorkOS Pipes requires a separate widget integration and offers no advantage over a direct OAuth App for our use case. It also auto-suggests odd scopes (e.g.
admin:repo_hook) when typingrepoin the dashboard, which is friction. - A custom GitHub OAuth App gives us the OAuth App's well-documented token-exchange endpoint, transparent error handling, and total control over scope (we request
read:user user:email repo).
Why not a GitHub App
GitHub App user-to-server tokens (installation tokens) require a separate App installation flow and the resulting tokens have broader repository scope than we need. GitHub OAuth App tokens are friendlier for the Vercel-style "Connect GitHub" UX.
Token storage
The token is stored on the users.github_access_token column (encrypted at rest in production). The token is held server-side only; the API never echoes it back to the browser. The session cookie is the only credential the browser ever sees.
Env vars
# WorkOS AuthKit (login)
WORKOS_API_KEY=sk_test_... # Mgmt API key, used by Go for user lookup
WORKOS_CLIENT_ID=client_... # OAuth application ID
WORKOS_AUTHKIT_DOMAIN=https://... # env-specific authkit domain (used for hosted login)
WORKOS_COOKIE_PASSWORD=... # ≥32 chars, shared between web and api
NEXT_PUBLIC_WORKOS_REDIRECT_URI=http://localhost:3000/callback
# GitHub OAuth App (repo access on /projects/new)
GITHUB_OAUTH_CLIENT_ID=... # OAuth App client ID
GITHUB_OAUTH_CLIENT_SECRET=... # OAuth App client secret
GITHUB_OAUTH_REDIRECT_URI=http://localhost:3000/api/auth/github/callback
Related
- Project: Bugpup