Demonstrates how to protect an Agents app with GitHub OAuth while keeping the Worker, not the browser, in charge of which Durable Object instance a user can reach.
The flow is:
- the browser signs in with GitHub
- GitHub returns an access token to the Worker
- the Worker stores that token in an httpOnly cookie
- the client connects to
/chat - the Worker resolves the authenticated GitHub user and forwards the request to
that user's
ChatAgentinstance withgetAgentByName()
- GitHub OAuth in a Worker without extra auth libraries
- httpOnly cookie auth instead of localStorage tokens
- Custom
basePathrouting so the server chooses the user-scoped agent name getAgentByName()+agent.fetch()to forward HTTP and WebSocket traffic
Go to GitHub OAuth Apps, create a new OAuth App, and set:
- Homepage URL:
http://localhost:5173 - Authorization callback URL:
http://localhost:5173/auth/callback
cp .env.example .envThen fill in:
GITHUB_CLIENT_ID=your-client-id
GITHUB_CLIENT_SECRET=your-client-secretnpm install
npm startOpen the app, click Sign in with GitHub, approve the OAuth flow, and you will land back in the chat UI as your GitHub user.
When you want the server to own the user identity and the DO name, custom
base-path routing is simpler than letting the browser choose an agent name.
The browser does not know or choose the Durable Object name. It just connects to
/chat:
const agent = useAgent({
agent: "ChatAgent",
basePath: "chat"
});The Worker reads the GitHub token from an httpOnly cookie, fetches the current GitHub user, and routes the request to the matching agent instance:
if (url.pathname === "/chat" || url.pathname.startsWith("/chat/")) {
const user = await getGitHubUserFromRequest(request);
if (!user) {
return createUnauthorizedResponse(request);
}
const agent = await getAgentByName(env.ChatAgent, user.login);
return agent.fetch(request);
}That same route covers both the WebSocket upgrade and the SDK's HTTP requests. No query-string token juggling, no localStorage, and no browser-controlled room names.
For simplicity, this example looks up the current GitHub user from GitHub's
/user API on each authenticated request. That's fine for a demo. In a
production app, you would usually cache the result or exchange the upstream
token for your own session.
Browser Worker Durable Object
────── ────── ──────────────
1. GET /auth/login ──► set state cookie + redirect
to GitHub authorize URL
2. GET /auth/callback ──► exchange code for access token
?code=...&state=... set httpOnly gh_access_token cookie
◄──── 302 /
3. GET /auth/me ──► call GitHub /user with cookie token
◄──── { id, login, name, avatarUrl }
4. WS /chat + HTTP /chat/* ──► call GitHub /user again
getAgentByName(env.ChatAgent, user.login)
◄──── forward request to that user-scoped agent
- Real identity from GitHub for a developer-facing example
- No localStorage auth state in the browser
- User-scoped routing owned by the Worker instead of client input
- One stable client path for both WebSocket and HTTP traffic
| File | Purpose |
|---|---|
src/auth.ts |
GitHub OAuth flow, cookie helpers, current-user lookup |
src/server.ts |
Worker entry and /chat custom routing |
src/auth-client.ts |
Client helpers for /auth/me, /auth/logout, login |
src/client.tsx |
Sign-in UI and authenticated chat |
.env.example |
Required GitHub OAuth env vars |
| Variable | Required | Description |
|---|---|---|
GITHUB_CLIENT_ID |
Yes | GitHub OAuth App client ID |
GITHUB_CLIENT_SECRET |
Yes | GitHub OAuth App client secret |
For a deployed Worker, create or update your GitHub OAuth App so it also has your production callback URL:
https://your-domain.example/auth/callback
Then set the secrets:
wrangler secret put GITHUB_CLIENT_ID
wrangler secret put GITHUB_CLIENT_SECRETFinally deploy:
npm run deploy- This example stores the GitHub access token directly in an httpOnly cookie. That keeps the browser code simple and avoids a separate session layer owned by the app.
- The GitHub auth cookie is a session cookie, so it lasts for the current browser session rather than for a fixed multi-day lifetime.
- The Durable Object name uses
user.loginso the demo stays readable. In a production app you may preferuser.idif you want a stable identifier that does not change when a username is renamed.
- ai-chat — chat agent without auth
- github-webhook — GitHub integration example without browser auth
- playground — broader examples including custom routing