GitHub Copilot device-code flow without a backend
Most Copilot integrations are IDE plugins. They run locally, can talk to localhost callback servers, can store secrets in OS keychains, can open browsers programmatically. None of that is available to a static webpage on GitHub Pages.
The virtual brainstem at kody-w.github.io/RAPP/brainstem/ authenticates users with GitHub Copilot anyway. The flow is end-to-end OAuth device-code — the same one the official Copilot CLI uses — adapted for a JavaScript caller running in a sandboxed browser tab. Three actors:
- The browser (the brainstem’s JavaScript)
- The Cloudflare Worker (
rapp-auth.kwildfeuer.workers.dev) - GitHub (
github.com/login/device/codeand friends)
The worker exists because GitHub’s OAuth endpoints don’t set CORS. The browser can’t call them directly. (See post #9.) The worker exists for one more critical reason: it holds the OAuth client secret. The secret is a wrangler secret put env var; the browser never sees it.
The flow:
-
User clicks “Sign in with GitHub” in the brainstem. The browser POSTs to
worker/api/auth/device. The worker callsgithub.com/login/device/codewith the client ID + secret and returns{ user_code, verification_uri, device_code, interval }. -
The brainstem opens a modal showing the user code. “Go to https://github.com/login/device and enter ABCD-1234.” The user copies the code or hits the link.
-
Background polling. The brainstem starts polling
worker/api/auth/device/pollwith thedevice_code, every 5 seconds. The worker proxies each poll togithub.com/login/oauth/access_token. While the user hasn’t approved yet, GitHub returnsauthorization_pending. We translate that into “still waiting” without surfacing as an error. -
User approves on github.com. Their next action approves the OAuth grant.
-
Next poll succeeds. GitHub returns
{ access_token: "ghu_...", scope: "...", token_type: "bearer" }. The worker passes this through. The brainstem storesghu_…inlocalStorageasstate.settings.ghuToken. -
Exchange for Copilot session bearer. The
ghu_…token isn’t accepted directly by Copilot’s chat endpoint. The brainstem POSTs toworker/api/copilot/tokenwith theghu_…. The worker callsapi.github.com/copilot_internal/v2/tokenand returns{ token: "...", endpoints: {api: "..."}, expires_at: 12345 }. The brainstem caches that bearer; it’s good for ~25 minutes. When it nears expiry, the brainstem re-exchanges silently. -
Chat. Each chat request goes through
worker/api/copilot/chatwith the session bearer. The worker forwards to the rightapi.individual.githubcopilot.comendpoint with the rightEditor-VersionandCopilot-Integration-Idheaders (Copilot’s API silently rejects requests without them).
The user sees: “Sign in with GitHub” → device code in a modal → return to chat. About 15 seconds end-to-end. They never gave us their password. They never installed anything. They never created an API key. We never see their credentials, only short-lived bearers we cache in their own browser.
The pieces:
- Browser: ~80 lines of JS for the polling loop and bearer cache.
- Worker: ~150 lines of TypeScript for the proxy + OAuth-secret-holding.
- GitHub: provides the device-code primitives.
Three players, two endpoints, no backend database, no user accounts. The whole identity story is “if you have a Copilot subscription you can use this.”
If you’re shipping a static site that needs Copilot (or any OAuth-protected API), this is the smallest workable architecture. The worker is the only part that holds secrets. The browser holds short-lived bearers. The user holds their GitHub identity. Nobody holds their password.