MCP OAuth Flow
Waxell's MCP server uses OAuth 2.0 with PKCE so users authenticate in the browser instead of copying API keys into config files. When you connect from Claude Desktop, Claude Code, or another MCP client, a browser window opens, you sign in, and the client receives tokens automatically.
If you want to connect Claude Desktop or Claude Code to Waxell, see Connect via OAuth for a step-by-step guide. This page covers the protocol internals.
The implementation follows four RFCs: RFC 6749 (OAuth 2.0), RFC 7636 (PKCE), RFC 7591 (Dynamic Client Registration), and RFC 8414 (Authorization Server Metadata).
How It Works
The complete flow has six steps:
- Connect — MCP client sends an initial request to the MCP server, receives
401 Unauthorizedwith a pointer to the authorization server - Discovery — Client fetches
/.well-known/oauth-authorization-serverto learn the endpoint URLs - Client Registration — Client registers itself via Dynamic Client Registration, receives a
client_idandclient_secret - Authorization — Client opens the browser; user signs in (and completes MFA if enabled); server issues an authorization code
- Token Exchange — Client exchanges the authorization code (with PKCE verifier) for access and refresh tokens
- Authenticated Access — Client sends the access token as a Bearer header with every MCP request
| Step | Who does it | User-visible? |
|---|---|---|
| Connect | MCP client | No |
| Discovery | MCP client | No |
| Client Registration | MCP client | No |
| Authorization | User in browser | Yes — you sign in here |
| Token Exchange | MCP client | No |
| Authenticated Access | MCP client | No |
From the user's perspective, the only step is signing in. Everything else is handled automatically by the MCP client.
Step 1: Discovery
The client fetches the authorization server metadata to learn where to send registration, authorization, and token requests.
Request:
GET /.well-known/oauth-authorization-server HTTP/1.1
Host: app.waxell.dev
Response:
{
"issuer": "https://app.waxell.dev",
"authorization_endpoint": "https://app.waxell.dev/api/oauth/mcp/authorize/",
"token_endpoint": "https://app.waxell.dev/api/oauth/mcp/token/",
"registration_endpoint": "https://app.waxell.dev/api/oauth/mcp/register/",
"response_types_supported": ["code"],
"grant_types_supported": ["authorization_code", "refresh_token"],
"token_endpoint_auth_methods_supported": [
"client_secret_post",
"client_secret_basic"
],
"code_challenge_methods_supported": ["S256"]
}
| Field | Meaning |
|---|---|
issuer | Base URL of the authorization server |
authorization_endpoint | Where to send the user for login |
token_endpoint | Where to exchange codes for tokens |
registration_endpoint | Where to register new clients (DCR) |
response_types_supported | Only code (authorization code grant) |
grant_types_supported | Authorization code and refresh token |
token_endpoint_auth_methods_supported | Client authenticates via POST body or Basic auth |
code_challenge_methods_supported | Only S256 (SHA-256 PKCE) |
Step 2: Client Registration
The client registers itself with the authorization server. This creates a unique client_id and client_secret for this client instance.
Request:
POST /api/oauth/mcp/register/ HTTP/1.1
Host: app.waxell.dev
Content-Type: application/json
{
"client_name": "Claude Desktop",
"redirect_uris": ["http://127.0.0.1:49152/oauth/callback"],
"grant_types": ["authorization_code"]
}
Response:
{
"client_id": "mcp-aBcDeFgHiJkLmNoPqRsTuV",
"client_secret": "xYzAbCdEfGhIjKlMnOpQrStUvWxYz012345678901",
"client_name": "Claude Desktop",
"redirect_uris": ["http://127.0.0.1:49152/oauth/callback"],
"grant_types": ["authorization_code"],
"token_endpoint_auth_method": "client_secret_post"
}
Client registrations are cached for 30 days. The client does not re-register every time it connects — it reuses its existing client_id and client_secret until they expire.
Step 3: Authorization
The client generates a PKCE code verifier and challenge, then opens the user's browser to the authorization endpoint.
Authorization URL:
https://app.waxell.dev/api/oauth/mcp/authorize/
?client_id=mcp-aBcDeFgHiJkLmNoPqRsTuV
&response_type=code
&redirect_uri=http://127.0.0.1:49152/oauth/callback
&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
&code_challenge_method=S256
&state=abc123xyz
&resource=https://dev-mcp.waxell.dev/mcp
PKCE explained: The client generates a random code_verifier (a high-entropy string). It computes code_challenge = base64url(sha256(code_verifier)) and sends the challenge in the authorization request. Later, during token exchange, the client sends the original code_verifier so the server can verify they match. This prevents authorization code interception — even if an attacker captures the code, they cannot exchange it without the verifier.
Login form: The authorization endpoint renders a branded login form. The user enters their email and password directly on this page.
Multi-Factor Authentication
When MFA is enabled on the user's account, a second verification step appears after password authentication.
Supported methods:
- TOTP — 6-digit time-based codes from an authenticator app (Google Authenticator, Authy, 1Password, etc.). Codes are accepted within a +/- 30 second window (
valid_window=1). - Backup codes — Single-use 8-character recovery codes. Each code is consumed (permanently removed) when used.
MFA session behavior:
- After entering a correct password, the server creates a temporary MFA session with a 5-minute timeout
- The user has a maximum of 5 attempts to enter a valid code
- After 5 failed attempts, the MFA session is destroyed and the user must restart the login from email/password
- If the 5-minute timeout expires before a valid code is entered, the session is destroyed
If your authenticator app's clock is out of sync, TOTP codes may be rejected. Ensure your device's time is set automatically. If TOTP is not working, use a backup code instead.
Auth code redirect: On successful authentication (and MFA if enabled), the browser redirects to the client's callback URL with an authorization code:
http://127.0.0.1:49152/oauth/callback?code=RaNdOmSeCuReCoDeHeRe&state=abc123xyz
The authorization code expires in 5 minutes and is single-use — it is consumed on the first exchange attempt.
Step 4: Token Exchange
The client exchanges the authorization code for access and refresh tokens, including the PKCE code_verifier for proof.
Request:
POST /api/oauth/mcp/token/ HTTP/1.1
Host: app.waxell.dev
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code
&code=RaNdOmSeCuReCoDeHeRe
&redirect_uri=http://127.0.0.1:49152/oauth/callback
&client_id=mcp-aBcDeFgHiJkLmNoPqRsTuV
&client_secret=xYzAbCdEfGhIjKlMnOpQrStUvWxYz012345678901
&code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk
Response:
{
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyLXV1aWQiLCJ0ZW5hbnRfaWQiOiJ0ZW5hbnQtdXVpZCIsImFwaV9rZXkiOiJ3YXhfc2tfLi4uIiwiYXVkIjoiaHR0cHM6Ly9kZXYtbWNwLndheGVsbC5kZXYvbWNwIiwiaXNzIjoiaHR0cHM6Ly9hcHAud2F4ZWxsLmRldiIsImlhdCI6MTcwODAwMDAwMCwiZXhwIjoxNzA4MDAzNjAwLCJ0eXBlIjoiYWNjZXNzIn0.signature",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyLXV1aWQiLCJ0ZW5hbnRfaWQiOiJ0ZW5hbnQtdXVpZCIsImFwaV9rZXkiOiJ3YXhfc2tfLi4uIiwiYXVkIjoiaHR0cHM6Ly9kZXYtbWNwLndheGVsbC5kZXYvbWNwIiwiaXNzIjoiaHR0cHM6Ly9hcHAud2F4ZWxsLmRldiIsImlhdCI6MTcwODAwMDAwMCwiZXhwIjoxNzEwNTkyMDAwLCJ0eXBlIjoicmVmcmVzaCJ9.signature"
}
After this step, the client includes the access token with every MCP request:
Authorization: Bearer eyJhbGciOiJIUzI1NiIs...
Token Lifecycle
| Token | Lifetime | Purpose |
|---|---|---|
| Access token | 1 hour | Authenticates MCP requests |
| Refresh token | 30 days | Obtains new access tokens without re-login |
| Authorization code | 5 minutes | One-time code exchanged for tokens |
| Client registration | 30 days | Cached client credentials from DCR |
Refresh Flow
When the access token expires, the MCP client automatically refreshes it using the refresh token. No user interaction is needed.
Request:
POST /api/oauth/mcp/token/ HTTP/1.1
Host: app.waxell.dev
Content-Type: application/x-www-form-urlencoded
grant_type=refresh_token
&refresh_token=eyJhbGciOiJIUzI1NiIs...
&client_id=mcp-aBcDeFgHiJkLmNoPqRsTuV
Response:
{
"access_token": "eyJhbGciOiJIUzI1NiIs...(new token)",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "eyJhbGciOiJIUzI1NiIs...(new refresh token)"
}
Both a new access token and a new refresh token are issued. The new refresh token has a fresh 30-day expiry.
When the refresh token expires (after 30 days of no use), the user must re-authenticate in the browser. The MCP client will open the browser again automatically.
Security Model
- PKCE prevents code interception — The authorization code is useless without the
code_verifier, which never leaves the client. Even if an attacker intercepts the redirect, they cannot exchange the code for tokens. - Passwords never touch the MCP client — Users enter credentials only in the browser login form. The MCP client never sees or stores the password.
- Scoped API keys — Each MCP connection gets its own API key embedded in the JWT
api_keyclaim. The MCP server extracts it to make authenticated API calls on behalf of the user. - Tenant isolation — JWTs contain a
tenant_idclaim. The MCP server can only access data belonging to that tenant. - Short-lived authorization codes — Auth codes are single-use and expire in 5 minutes, minimizing the window for interception.
Error Reference
| Error | HTTP Status | Meaning |
|---|---|---|
{"error": "invalid_request", "error_description": "PKCE S256 required"} | 400 | The authorization request is missing code_challenge or code_challenge_method is not S256. All requests must use PKCE with SHA-256. |
{"error": "invalid_client"} | 400 / 401 | The client_id is not recognized. The client registration may have expired (30-day TTL) or the ID is malformed. Re-register the client. |
{"error": "invalid_grant", "error_description": "Invalid or expired code"} | 400 | The authorization code has already been used (single-use) or has expired (5-minute TTL). The user needs to re-authenticate. |
{"error": "invalid_grant", "error_description": "PKCE verification failed"} | 400 | The code_verifier sent during token exchange does not match the code_challenge from the authorization request. This usually means the client sent a different verifier than the one used to generate the challenge. |
{"error": "access_denied", "error_description": "No tenant found"} | 403 | The authenticated user has no active workspace/tenant. The user needs to be invited to a team or complete their account setup. |
{"error": "unsupported_response_type"} | 400 | The response_type parameter is not code. Only the authorization code grant is supported. |
{"error": "invalid_client_metadata", "error_description": "redirect_uris required"} | 400 | The client registration request is missing redirect_uris. At least one redirect URI must be provided. |