Multi-tenant OAuth 2.1 gateway in front of the official github-mcp-server. Each MCP client (claude.ai, Claude Desktop, etc.) authorizes with its own GitHub OAuth identity; the gateway swaps in that user's GitHub token before forwarding to the upstream container.
claude.ai ──OAuth 2.1 (PKCE, DCR)──> https://github.nlma.io (nginx)
└─> 127.0.0.1:3061 github-mcp-auth (this server)
│ (validates opaque bearer, swaps Authorization header)
└─> 127.0.0.1:3060 github-mcp-server (official container)
└─> api.github.com (as the actual user)
- claude.ai discovers OAuth metadata at
/.well-known/oauth-protected-resource+/.well-known/oauth-authorization-server(served bymcpAuthRouterfrom@modelcontextprotocol/sdk). - claude.ai registers itself via Dynamic Client Registration (RFC 7591) at
POST /register. - claude.ai sends the user's browser to
/authorize?...&code_challenge=...&code_challenge_method=S256. - The gateway stashes the PKCE state in Postgres under a random
state_token, then 302-redirects the browser togithub.com/login/oauth/authorizewith that token as thestateparameter. - The user approves on GitHub; GitHub redirects back to
/oauth/github/callback?code=...&state=.... - The gateway exchanges GitHub's
codefor the user's GitHub access token, looks up their GitHub user id + login, encrypts the token with AES-256-GCM, and stores it ingithub_users. - The gateway 302-redirects the browser back to claude.ai's
redirect_uriwith our own auth code (opaque UUID). - claude.ai POSTs
/tokento exchange that code for an opaque access token (also a UUID, points at the GitHub user). - On every subsequent
POST /mcp, the gateway: validates the opaque bearer → looks up the underlying GitHub user → refreshes the GitHub token if expiring → rewritesAuthorization: Bearer <user's GitHub token>→ reverse-proxies to127.0.0.1:3060.
github-mcp-server itself is unchanged; it just sees a normal authenticated request with a per-user GitHub token.
- Opaque tokens, not JWT. Matches the existing
mcpAuthRouterpattern inhospitable-mcpandskillbuilder-mcpon this VPS. Simpler revocation, no JWKS to publish or rotate. - Custom Express, not Authentik. ~500 LOC and one systemd unit. Authentik would be one more service to operate for a use case the SDK already covers.
Copy .env.example to .env and fill in. Required:
| Var | Notes |
|---|---|
DATABASE_URL |
Postgres. Reuse the existing VPS Postgres; use a dedicated DB github_mcp. |
API_KEY_HASH_SALT |
≥32 chars random. Drives AES-256-GCM key for token-at-rest and the per-user identifier hash. |
GITHUB_CLIENT_ID |
From the GitHub OAuth App you register (see below). |
GITHUB_CLIENT_SECRET |
Same. Treat as secret. .env should be chmod 600. |
BASE_URL |
https://github.nlma.io |
GITHUB_SCOPES |
Default repo,read:org,read:user,read:project,workflow. workflow is required to create/update .github/workflows/* files. Bump if a tool needs more. |
UPSTREAM_MCP_URL |
Default http://127.0.0.1:3060 — the github-mcp-server docker container. |
GITHUB_ALLOWED_USERS |
Optional CSV allowlist of GitHub logins. Empty = anyone with a GitHub account. |
- Register the GitHub OAuth App at https://github.com/settings/developers (or in an org if you want centralized management):
- Homepage URL:
https://github.nlma.io - Authorization callback URL:
https://github.nlma.io/oauth/github/callback - Enable Device Flow: no
- Webhook: off
- After creating, copy
Client IDand generate aClient secret. Paste into/opt/github-mcp-auth/.envon the VPS.
- Homepage URL:
- Create the Postgres database on the VPS:
Put the corresponding
sudo -u postgres psql -c "CREATE USER github_mcp WITH PASSWORD '<strong>';" sudo -u postgres psql -c "CREATE DATABASE github_mcp OWNER github_mcp;"
DATABASE_URLinto.env. - Generate a master encryption key:
Paste into
openssl rand -hex 32 # 64 hex chars = 64 bytes; well over the 32-char minimumAPI_KEY_HASH_SALTin.env. - Rotate the existing PAT (
ghp_GHh3rtDc...) in/opt/github-mcp/.env— it appeared in a chat transcript. Either rotate it or delete it entirely; once this gateway is live the static PAT is no longer the auth path.
From the laptop:
cd c:/Users/forre/Source/github-mcp-auth
npm install # local sanity
npm run build # local typecheck
bash scripts/push-to-vps.sh # uploads, builds on VPS, restarts serviceFirst-time installation on the VPS (after the first push-to-vps.sh has put the working tree at /opt/github-mcp-auth/):
ssh root@178.16.141.166 'bash /opt/github-mcp-auth/scripts/install-on-vps.sh'This:
- Installs
/etc/systemd/system/github-mcp-auth.serviceand enables it. - Installs
/etc/nginx/conf.d/limit-req-github.conf(rate limit zone). - Replaces
/etc/nginx/sites-enabled/github.nlma.ioto point at:3061(the gateway) instead of:3060(the bare container). - Runs
npm ci && npm run build && systemctl restart. - Curls
/healthto confirm.
https://github.nlma.io/mcp
That's it. claude.ai handles the OAuth dance; the user is redirected to GitHub to authorize on first connect.
# OAuth metadata
curl -s https://github.nlma.io/.well-known/oauth-protected-resource | jq .
curl -s https://github.nlma.io/.well-known/oauth-authorization-server | jq .
# Health
curl -s https://github.nlma.io/health
# Without a bearer, /mcp must 401 with a WWW-Authenticate header
curl -i https://github.nlma.io/mcp -X POST -H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}'See migrations/001_initial.sql + migrations/002_oauth.sql. Notable tables:
github_users— one row per GitHub user we've ever authenticated. Holds the encrypted access (and refresh) tokens.oauth_access_tokens— opaque tokens issued to claude.ai. Points atgithub_users.github_user_id.oauth_pending_state— short-lived rows for in-flight GitHub OAuth dances; carries the claude.ai PKCE challenge across the GitHub redirect.
- GitHub tokens are AES-256-GCM-encrypted at rest. Key is HKDF-derived from
API_KEY_HASH_SALTwith a distinct info label. - Tokens issued to claude.ai are opaque UUIDs; nothing about the GitHub user is recoverable from them without the database.
tenants.tenant_id_hashis a salted SHA-256 of the GitHub user id, so audit logs don't directly expose user ids.GITHUB_ALLOWED_USERSprovides a deny-by-default mode while testing.- Revoke a user:
DELETE FROM github_users WHERE github_login = '...'cascades effectively (their opaque tokens won't resolve, and proxy requests will 401).
Copyright © 2026 Next Level Management Advisors, LLC.
Licensed under the GNU Affero General Public License v3.0 (AGPL-3.0) — see LICENSE. If you run a modified version over a network, the AGPL requires you to make your modified source available to its users.
Commercial licensing: to use this in a closed-source or commercial product, or to host a modified version without publishing your source, a commercial license is available — contact forrest@nlma.io.