Skip to Content

OAuth 2.0 Authorization Code + PKCE

Secure delegated authorization for third-party clients and browser-based apps using the Authorization Code flow with PKCE (Proof Key for Code Exchange).

Scope migration: OAuth scopes changed from a2a.* to mcp:* in v0.7. See the scope migration guide before implementing.

Overview

The Authorization Code + PKCE flow lets a client application obtain access tokens on behalf of a user without ever handling the user’s password. PKCE (RFC 7636) protects against authorization code interception attacks and is required for all public clients (browser apps, mobile apps, CLI tools).

Client isA Auth Server User │ │ │ │── Generate code_verifier ───▶│ │ │ code_challenge = SHA256(verifier) │ │ │ │ │── GET /oauth/authorize ─────▶│ │ │ ?code_challenge=... │──── Login page ───▶│ │ &client_id=... │◀─── User consents ─│ │ &redirect_uri=... │ │ │◀─ ?code=AUTH_CODE ──────────│ │ │ │ │ │── POST /oauth/token ────────▶│ │ │ code + code_verifier │ │ │◀─ access_token + refresh ───│ │

Step 1 — Generate PKCE Parameters

import secrets import hashlib import base64 # Generate a cryptographically random code_verifier (43–128 chars) code_verifier = secrets.token_urlsafe(64) # 86-char URL-safe string # Derive code_challenge using S256 method digest = hashlib.sha256(code_verifier.encode()).digest() code_challenge = base64.urlsafe_b64encode(digest).rstrip(b"=").decode() print(f"verifier: {code_verifier}") print(f"challenge: {code_challenge}")
// Browser / Node.js const array = new Uint8Array(64); crypto.getRandomValues(array); const codeVerifier = btoa(String.fromCharCode(...array)) .replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''); const encoder = new TextEncoder(); const data = encoder.encode(codeVerifier); const digest = await crypto.subtle.digest('SHA-256', data); const codeChallenge = btoa(String.fromCharCode(...new Uint8Array(digest))) .replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');

Step 2 — Redirect to Authorization Endpoint

GET /oauth/authorize

Redirect the user’s browser to the authorization endpoint with the following query parameters:

ParameterRequiredDescription
client_idYesYour application’s client ID
redirect_uriYesWhere to send the user after consent
response_typeYesAlways code
scopeYesSpace-separated list of mcp:* scopes
code_challengeYesSHA-256 hash of code_verifier (base64url)
code_challenge_methodYesAlways S256
stateRecommendedRandom value to prevent CSRF
https://auth.isa.ai/oauth/authorize ?client_id=app_abc123 &redirect_uri=https://myapp.example.com/callback &response_type=code &scope=mcp%3Atools%3Aexecute+mcp%3Aresources%3Aread &code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM &code_challenge_method=S256 &state=xyzABC123

The user sees the consent screen at /oauth/consent listing the requested scopes with human-readable descriptions. After approving, they are redirected to your redirect_uri.

Step 3 — Receive the Authorization Code

After user consent, the auth server redirects to your redirect_uri with:

https://myapp.example.com/callback ?code=SplxlOBeZQQYbYS6WxSbIA &state=xyzABC123

Validate state before proceeding — if it doesn’t match what you sent, abort.

Authorization codes expire in 60 seconds and are single-use.

Step 4 — Exchange Code for Tokens

POST /oauth/token Content-Type: application/x-www-form-urlencoded
curl -X POST https://auth.isa.ai/oauth/token \ -H "Content-Type: application/x-www-form-urlencoded" \ -d "grant_type=authorization_code" \ -d "code=SplxlOBeZQQYbYS6WxSbIA" \ -d "redirect_uri=https://myapp.example.com/callback" \ -d "client_id=app_abc123" \ -d "code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"

Response:

{ "access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...", "token_type": "Bearer", "expires_in": 3600, "refresh_token": "tGzv3JOkF0XG5Qx2TlKWIA", "scope": "mcp:tools:execute mcp:resources:read" }

Step 5 — Use the Access Token

Include the access token as a Bearer token on API requests:

curl https://mcp.isa.ai/api/v1/tools/execute \ -H "Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9..."

Token Refresh

Access tokens expire in 1 hour. Refresh without re-prompting the user:

curl -X POST https://auth.isa.ai/oauth/token \ -H "Content-Type: application/x-www-form-urlencoded" \ -d "grant_type=refresh_token" \ -d "refresh_token=tGzv3JOkF0XG5Qx2TlKWIA" \ -d "client_id=app_abc123"

Refresh tokens do not expire but are invalidated on logout or password change.

Public vs Confidential Clients

Client typeclient_secret requiredExamples
PublicNoSPA, mobile app, CLI tool
ConfidentialYes (in token request)Server-side web app

Confidential clients send client_secret in the token exchange request:

-d "client_secret=YOUR_SECRET"

Public clients rely on PKCE alone — never embed a client_secret in client-side code.

Authorization Server Metadata

Discover all OAuth endpoints automatically:

curl https://auth.isa.ai/.well-known/oauth-authorization-server
{ "issuer": "https://auth.isa.ai", "authorization_endpoint": "https://auth.isa.ai/oauth/authorize", "token_endpoint": "https://auth.isa.ai/oauth/token", "response_types_supported": ["code"], "grant_types_supported": ["authorization_code", "refresh_token"], "code_challenge_methods_supported": ["S256"], "scopes_supported": [ "mcp:tools:execute", "mcp:tools:read", "mcp:resources:read", "mcp:resources:write", "mcp:admin:servers", "mcp:admin:tenants" ], "client_id_metadata_document_supported": true }

Error Handling

ErrorCauseResolution
invalid_requestMissing required parameterCheck all required params are present
invalid_clientUnknown client_idVerify client registration
invalid_grantExpired or used codeRestart the flow
invalid_pkcecode_verifier doesn’t match challengeRegenerate PKCE pair and restart
access_deniedUser rejected consentInform user and offer retry
unsupported_grant_typeWrong grant_type valueUse authorization_code

Next Steps