OAuth 2.0 + PKCE for MCP servers — the auth flow explained

How Bindly implements OAuth 2.0 with PKCE for MCP clients (Claude, Cursor, other LLM tools), why PKCE is required for public clients, and what the full authorization flow looks like from the MCP client's perspective.


# OAuth 2.0 + PKCE for MCP servers — the auth flow explained When Claude connects to Bindly's MCP server, it initiates an OAuth 2.0 authorization flow. We didn't design this flow — MCP clients require it. But understanding why it works this way is important for anyone building an MCP server that needs auth. The key insight: MCP clients are **public clients**. They run on the user's machine and cannot safely store a client secret. PKCE solves this. ## Why PKCE is needed Standard OAuth 2.0 for web apps uses a client secret: ```text Client → Auth server: GET /oauth/authorize?client_id=abc&client_secret=xyz Server → Client: authorization_code=abc123 Client → Auth server: POST /oauth/token { code: abc123, client_secret: xyz } Server → Client: { access_token: ... } ``` The client secret proves the token exchange was legitimate. But MCP clients are desktop applications — anyone can inspect the code and extract the "secret." It's not a secret at all. PKCE replaces the client secret with a **cryptographic challenge** generated fresh for each authorization attempt. ## The PKCE flow ### Step 1: Generate code_verifier and code_challenge The MCP client generates these locally, on the user's machine: ```javascript // In the MCP client (Claude Desktop, Cursor, etc.) const codeVerifier = generateRandomString(64) // 64 URL-safe base64 chars const codeChallenge = base64url(sha256(codeVerifier)) // SHA-256, then base64url ``` The `codeVerifier` never leaves the client. The `codeChallenge` is a one-way hash of it. This is the key property: you can verify the exchange without ever transmitting the verifier. ### Step 2: Redirect to Bindly's authorization endpoint ```http GET mcp.bind.ly/oauth/authorize ?response_type=code &client_id=bindly-mcp &redirect_uri=http://localhost:8765/callback &code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cg &code_challenge_method=S256 &state=xyzABC &scope=read write ``` The MCP Worker shows the user a login page. After authentication, the server: 1. Creates an authorization code 2. Stores it in D1: `INSERT INTO oauth_codes (code, user_id, client_id, code_challenge, expires_at, ...)` 3. Redirects to `redirect_uri?code=abc123&state=xyzABC` The `code_challenge` is stored alongside the code. It will be used to verify the exchange. ### Step 3: Exchange code for access token The MCP client now has the authorization code. It sends the `code_verifier` for the first (and only) time: ```http POST mcp.bind.ly/oauth/token Content-Type: application/x-www-form-urlencoded grant_type=authorization_code &code=abc123 &redirect_uri=http://localhost:8765/callback &client_id=bindly-mcp &code_verifier=abc...xyz ``` The server verifies the exchange: ```typescript // In MCP Worker: POST /oauth/token handler const { code, code_verifier, client_id } = body // 1. Look up the authorization code const oauthCode = await db.prepare( 'SELECT * FROM oauth_codes WHERE code = ? AND expires_at > ?' ).bind(code, Date.now()).first() if (!oauthCode) return c.json({ error: 'invalid_grant' }, 400) // 2. Verify PKCE: hash the provided verifier and compare to stored challenge const computedChallenge = base64url(await sha256(code_verifier)) if (computedChallenge !== oauthCode.code_challenge) { return c.json({ error: 'invalid_grant' }, 400) } // 3. Consume the code (prevent replay) await db.prepare('DELETE FROM oauth_codes WHERE code = ?').bind(code).run() // 4. Issue access token const accessToken = await generateJWT({ sub: oauthCode.user_id, typ: 'access', aud: 'bindly-api', exp: Math.floor(Date.now() / 1000) + 900 // 15 minutes }) return c.json({ access_token: accessToken, token_type: 'bearer', expires_in: 900 }) ``` The security property: an attacker who intercepts the authorization code (step 2's redirect) cannot exchange it for a token without the `code_verifier`. They'd need to invert SHA-256. This is why PKCE is the right solution for public clients. ## The D1 oauth_codes table ```sql CREATE TABLE oauth_codes ( id TEXT PRIMARY KEY, user_id TEXT NOT NULL REFERENCES users(id), client_id TEXT NOT NULL, code TEXT NOT NULL UNIQUE, code_challenge TEXT NOT NULL, code_challenge_method TEXT NOT NULL DEFAULT 'S256', redirect_uri TEXT, scope TEXT, expires_at INTEGER NOT NULL, -- 10-minute TTL created_at INTEGER NOT NULL ); ``` Authorization codes are deleted immediately after use. Expired codes are cleaned up by the Cron Trigger: ```typescript await db.prepare('DELETE FROM oauth_codes WHERE expires_at < ?') .bind(Date.now()).run() ``` ## JWT structure Access tokens are JWTs signed with `JWT_SECRET`: ```json { "sub": "usr_abc123", "typ": "access", "aud": "bindly-api", "iat": 1744000000, "exp": 1744000900 } ``` The `typ` and `aud` claims differentiate access tokens from refresh tokens. This matters: ```typescript // Validation in API Worker auth middleware const payload = await jose.jwtVerify(token, jwtSecret, { audience: 'bindly-api' }) if (payload.typ !== 'access') throw new Error('Not an access token') ``` A refresh token (`typ: 'refresh', aud: 'bindly-refresh'`) presented as an access token fails audience validation. Token confusion attacks don't work. ## Three authentication modes The API Worker's auth middleware supports three modes, checked in order: ### Mode 1: Session cookie (Web SPA) ```typescript const sessionToken = getCookie(c, 'bindly_session') if (sessionToken) { const payload = await verifyJWT(sessionToken, { audience: 'bindly-api' }) c.set('user', payload) return next() } ``` The session cookie is an access JWT. HttpOnly, Secure, SameSite=Strict. ### Mode 2: User Key (MCP personal use) ```typescript const authHeader = c.req.header('Authorization') ?? '' if (authHeader.startsWith('Bearer userk_')) { const rawKey = authHeader.slice(7) const keyHash = await sha256(rawKey) const userKey = await db.prepare( 'SELECT * FROM user_keys WHERE key_hash = ?' ).bind(keyHash).first() if (!userKey) return c.json({ error: 'Unauthorized' }, 401) await db.prepare('UPDATE user_keys SET last_used_at = ? WHERE id = ?') .bind(Date.now(), userKey.id).run() c.set('user', { id: userKey.user_id }) return next() } ``` User Keys (`userk_...`) are generated in settings. Stored as SHA-256 hashes — the raw key is only shown once. ### Mode 3: Space API Key (automation) ```typescript if (authHeader.startsWith('Bearer spacek_')) { const rawKey = authHeader.slice(7) const keyHash = await sha256(rawKey) const spaceKey = await db.prepare( 'SELECT * FROM space_api_keys WHERE key_hash = ?' ).bind(keyHash).first() if (!spaceKey) return c.json({ error: 'Unauthorized' }, 401) const permissions = JSON.parse(spaceKey.permissions ?? '[]') c.set('apiKey', { spaceId: spaceKey.space_id, permissions }) return next() } ``` Space API Keys are scoped to a specific Space. They're designed for automation workflows — writing build artifacts to a Space, for example. OAuth Bearer tokens from MCP clients are JWTs (`Bearer eyJ...`), handled the same as session cookies but from the Authorization header instead. ## The `/api/mcp/*` restriction Routes under `/api/mcp/*` only accept User Keys and OAuth tokens — not session cookies: ```typescript app.use('/api/mcp/*', async (c, next) => { const authHeader = c.req.header('Authorization') ?? '' if (!authHeader.startsWith('Bearer ')) { return c.json({ error: 'MCP routes require Bearer token' }, 401) } return next() }) ``` This prevents web sessions from accidentally hitting MCP-specific endpoints. The MCP response format (with `_meta.context` and `content: [{ type: "text" }]`) is not appropriate for the web SPA. ## Stateless password reset Password reset uses a stateless JWT that self-invalidates when the password changes: ```typescript // Generate reset token const resetToken = await generateJWT({ sub: user.id, typ: 'password-reset', aud: 'bindly-reset', // Include password_hash fingerprint so this token is invalid // after the password changes pwh: user.password_hash.slice(-8), exp: Math.floor(Date.now() / 1000) + 3600 // 1 hour }) ``` When the user submits a new password, we re-fetch their current `password_hash` and verify the fingerprint matches. If they've already reset once, the fingerprint is wrong and the token is rejected. No reset code table. No Cron cleanup for reset tokens. The JWT `exp` and the password fingerprint `pwh` are the only state needed. We got this idea from thinking hard about what state is actually necessary — the answer was: none, the password hash itself is the state.