Skip to main content

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.

Just want to connect?

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:

  1. Connect — MCP client sends an initial request to the MCP server, receives 401 Unauthorized with a pointer to the authorization server
  2. Discovery — Client fetches /.well-known/oauth-authorization-server to learn the endpoint URLs
  3. Client Registration — Client registers itself via Dynamic Client Registration, receives a client_id and client_secret
  4. Authorization — Client opens the browser; user signs in (and completes MFA if enabled); server issues an authorization code
  5. Token Exchange — Client exchanges the authorization code (with PKCE verifier) for access and refresh tokens
  6. Authenticated Access — Client sends the access token as a Bearer header with every MCP request
StepWho does itUser-visible?
ConnectMCP clientNo
DiscoveryMCP clientNo
Client RegistrationMCP clientNo
AuthorizationUser in browserYes — you sign in here
Token ExchangeMCP clientNo
Authenticated AccessMCP clientNo
tip

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"]
}
FieldMeaning
issuerBase URL of the authorization server
authorization_endpointWhere to send the user for login
token_endpointWhere to exchange codes for tokens
registration_endpointWhere to register new clients (DCR)
response_types_supportedOnly code (authorization code grant)
grant_types_supportedAuthorization code and refresh token
token_endpoint_auth_methods_supportedClient authenticates via POST body or Basic auth
code_challenge_methods_supportedOnly 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"
}
note

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
caution

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

TokenLifetimePurpose
Access token1 hourAuthenticates MCP requests
Refresh token30 daysObtains new access tokens without re-login
Authorization code5 minutesOne-time code exchanged for tokens
Client registration30 daysCached 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.

note

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_key claim. The MCP server extracts it to make authenticated API calls on behalf of the user.
  • Tenant isolation — JWTs contain a tenant_id claim. 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

ErrorHTTP StatusMeaning
{"error": "invalid_request", "error_description": "PKCE S256 required"}400The 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 / 401The 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"}400The 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"}400The 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"}403The 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"}400The response_type parameter is not code. Only the authorization code grant is supported.
{"error": "invalid_client_metadata", "error_description": "redirect_uris required"}400The client registration request is missing redirect_uris. At least one redirect URI must be provided.