Authenticate MCP (Model Context Protocol) clients using Dynamic Client Registration and the authorization code flow with PKCE. No client secret required.
This page is for MCP clients onlyThis guide documents the OAuth flow used by MCP (Model Context Protocol) clients that register themselves with Productboard at runtime. It is not a general REST API authentication method — for that, see Authentication.
If you just want to connect an existing agent (Claude Code, Cursor, …) to Productboard, you don't need anything here — see Connecting your agent. This page is for engineers building their own MCP client who need to implement registration and sign-in by hand.
OAuth for MCP Clients with PKCE
Overview
MCP (Model Context Protocol) clients are applications that cannot securely store a client secret. They use Dynamic Client Registration to register themselves at runtime and PKCE (Proof Key for Code Exchange) instead of a client secret to secure the authorization code flow.
When to use this flow
Use this flow when:
- You're building an MCP client that connects to Productboard on a user's behalf.
- Your client registers itself programmatically rather than being created manually in the admin UI.
- You need user consent and refreshable access tokens, without a client secret.
This flow is specific to MCP clients. For other use cases, pick the matching method from Authentication: use the standard OAuth 2.0 Authorization Code flow for server-side apps that can store a secret, or the Server-to-Server (JWT) Flow for backend-to-backend automation.
How it works (at a glance)
- Register your application programmatically via the Dynamic Client Registration endpoint (RFC 7591). Public clients are registered at runtime by the client itself, not created manually in the Productboard admin UI.
- Your app generates a random code verifier and computes its SHA-256 hash (the code challenge).
- Your app redirects the user to the authorization endpoint with the code challenge.
- The user authorizes access; Productboard redirects back with an authorization code.
- Your app exchanges the code for an access token, sending the original code verifier for verification.
Security model
- No client secret: Public clients do not receive or store a client secret. PKCE replaces the secret as the proof of the legitimate client.
- S256 only: Only the
S256challenge method is accepted. Theplainmethod is rejected. - One-time use: Each code verifier/challenge pair is used exactly once.
- Short-lived codes: Authorization codes expire after 10 minutes.
Registering an OAuth Application
Public clients are registered programmatically via the OAuth 2.0 Dynamic Client Registration endpoint (RFC 7591). There is no manual admin UI for public clients — your application self-registers at runtime (typically at first launch or install) and receives a Client ID it can persist and reuse.
Endpoint: POST https://app.productboard.com/oauth2/register
Content-Type: application/json
Request body:
| Parameter | Description | Required |
|---|---|---|
redirect_uris | Array of redirect URIs. HTTPS is required except for localhost URIs used during local development. | Yes |
client_name | Human-readable name of the application. Shown to users on the consent screen. | Yes |
token_endpoint_auth_method | Must be none (public client, PKCE instead of secret). If omitted, defaults to none. | No |
scope | Space-separated list of requested scopes. If omitted, a default scope set is granted. | No |
Example request:
curl -X POST "https://app.productboard.com/oauth2/register" \
-H "Content-Type: application/json" \
-d '{
"client_name": "AwesomeMCP",
"redirect_uris": ["http://localhost:8080/callback"],
"token_endpoint_auth_method": "none",
"scope": "entities:read notes:read"
}'Successful response (201):
{
"client_id": "hqAGVdAy9FZX5Ky5cBHUB2FshBdSO6eN75tWMX46ZZd",
"client_id_issued_at": 1755600000,
"client_name": "AwesomeCLI",
"redirect_uris": ["http://localhost:8080/callback"],
"grant_types": ["authorization_code"],
"response_types": ["code"],
"token_endpoint_auth_method": "none",
"scope": "entities:read notes:read"
}Persist the returned client_id. No client secret is issued — public clients use PKCE in place of a secret.
Rate limits: Registration is rate-limited to 5 requests per minute and 50 requests per day per remote IP. Register once per installation and cache the
client_id, rather than registering on every run.
Registration errors
| Error code | When it happens |
|---|---|
invalid_redirect_uri | A redirect URI is missing, malformed, or uses a forbidden scheme. |
invalid_client_metadata | Other request fields failed validation (e.g. missing client_name, unsupported token_endpoint_auth_method, unknown scope). |
The response also includes an error_description field with a human-readable explanation.
Discovery
The registration endpoint is advertised by the OAuth 2.0 Authorization Server Metadata document at https://app.productboard.com/.well-known/oauth-authorization-server as registration_endpoint.
Integration Guide
1) Generate the PKCE Code Verifier and Challenge
Before starting the authorization flow, generate a cryptographically random code verifier and compute its code challenge.
Requirements:
code_verifier: A random string, 43-128 characters, using unreserved characters ([A-Za-z0-9._~-]).code_challenge: The Base64-URL-encoded SHA-256 hash of the code verifier (no padding).
JavaScript / Node.js:
const crypto = require('crypto');
// Generate code verifier (43-128 chars)
const codeVerifier = crypto.randomBytes(32).toString('base64url');
// Compute code challenge (S256)
const codeChallenge = crypto
.createHash('sha256')
.update(codeVerifier)
.digest('base64url');Python:
import secrets
import hashlib
import base64
# Generate code verifier
code_verifier = secrets.token_urlsafe(32)
# Compute code challenge (S256)
digest = hashlib.sha256(code_verifier.encode('ascii')).digest()
code_challenge = base64.urlsafe_b64encode(digest).rstrip(b'=').decode('ascii')Ruby:
require 'securerandom'
require 'digest'
require 'base64'
# Generate code verifier
code_verifier = SecureRandom.urlsafe_base64(32)
# Compute code challenge (S256)
digest = Digest::SHA256.digest(code_verifier)
code_challenge = Base64.urlsafe_encode64(digest, padding: false)Store the code_verifier securely in your app. You will need it when exchanging the authorization code for a token.
2) Get the Authorization Code
Redirect the user to the authorization endpoint with the following query parameters:
| Parameter | Description | Required |
|---|---|---|
| client_id | Your application's Client ID | Yes |
| response_type | Always code | Yes |
| redirect_uri | One of your registered redirect URIs (URL-encoded). Must be an exact match. | Yes |
| code_challenge | The Base64-URL-encoded SHA-256 hash of your code verifier | Yes |
| code_challenge_method | Always S256 | Yes |
| scope | Space-separated list of requested scopes. If omitted, the application's default scopes are used. | No |
| state | An arbitrary string returned unchanged in the response. Recommended for CSRF prevention. | No |
Example authorization URL:
https://app.productboard.com/oauth2/authorize
?client_id=YOUR_CLIENT_ID
&response_type=code
&redirect_uri=https%3A%2F%2Fyour-app.com%2Fcallback
&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
&code_challenge_method=S256
&scope=entities%3Aread%20notes%3Aread
&state=abc123
Important: If you omit
code_challengeor usecode_challenge_method=plain, the request will be rejected with an error.
After the user authorizes access, they are redirected to your redirect_uri with:
| Parameter | Description |
|---|---|
| code | The authorization code (use within 10 minutes) |
| state | The same value you sent (if provided) |
Example callback: https://your-app.com/callback?code=AUTH_CODE&state=abc123
3) Exchange the Authorization Code for an Access Token
Make a POST request to the token endpoint to exchange the authorization code for an access token. Send the code verifier (not the challenge) so the server can verify it matches.
Endpoint: POST https://app.productboard.com/oauth2/token
Content-Type: application/x-www-form-urlencoded
| Parameter | Description | Required |
|---|---|---|
| client_id | Your application's Client ID | Yes |
| grant_type | Always authorization_code | Yes |
| code | The authorization code from the previous step | Yes |
| redirect_uri | The same redirect URI used in the authorization request | Yes |
| code_verifier | The original code verifier you generated in step 1 | Yes |
Note: No
client_secretis needed. Thecode_verifierproves you are the same client that initiated the authorization request.
Example request:
curl -X POST "https://app.productboard.com/oauth2/token" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "client_id=YOUR_CLIENT_ID" \
-d "grant_type=authorization_code" \
-d "code=AUTH_CODE" \
-d "redirect_uri=https://your-app.com/callback" \
-d "code_verifier=YOUR_CODE_VERIFIER"Successful response (200):
{
"access_token": "eyJ0eXAiOiJKV1QiLCJraWQ...",
"token_type": "Bearer",
"expires_in": 86400,
"refresh_token": "4w4_-gHaOVixNuS_naAvqsRTsCuV7wWQgn0jXQYtUhs",
"refresh_token_expires_in": 15552000,
"scope": "entities:read notes:read",
"created_at": 1692288000
}| Field | Description |
|---|---|
| access_token | Bearer token for API requests (JWT format) |
| token_type | Always Bearer |
| expires_in | Seconds until the access token expires (24 hours) |
| refresh_token | Token to obtain a new access token when it expires |
| refresh_token_expires_in | Seconds until the refresh token expires (180 days) |
| scope | The granted scopes |
| created_at | UNIX timestamp of token creation |
4) Refresh the Access Token
When the access token expires, use the refresh token to obtain a new one. Public clients do not need a client secret for refresh requests.
Endpoint: POST https://app.productboard.com/oauth2/token
| Parameter | Description | Required |
|---|---|---|
| client_id | Your application's Client ID | Yes |
| grant_type | Always refresh_token | Yes |
| refresh_token | The refresh token from the token response | Yes |
Example request:
curl -X POST "https://app.productboard.com/oauth2/token" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "client_id=YOUR_CLIENT_ID" \
-d "grant_type=refresh_token" \
-d "refresh_token=YOUR_REFRESH_TOKEN"The response format is the same as the initial token exchange, with fresh access and refresh tokens.
Warning: If you lose both the access and refresh tokens, the user must re-authorize your application.
Error Handling
Authorization errors
If the authorization request is missing code_challenge or uses an unsupported method:
| Error | Description |
|---|---|
Missing code_challenge | Returns HTTP 401 with message: "PKCE code_challenge is required for this application." |
code_challenge_method=plain | Returns HTTP 400 with message: "The code challenge method is not supported." |
Token exchange errors
| Error | When it happens |
|---|---|
invalid_grant | The authorization code is invalid, expired, or already used. |
invalid_grant | The code_verifier does not match the code_challenge sent during authorization. |
invalid_request | Required parameters are missing. |
Credential Expirations
| Credential | Expiration | How to refresh |
|---|---|---|
| Authorization code | 10 minutes (or immediately after being used) | New authorization request |
| Access token | 24 hours | Refresh token |
| Refresh token | 180 days (or 60 minutes after being used) | Refresh token / New authorization request |
Access Scopes
Public client applications support the same scopes as other OAuth applications. See the Access Scopes section of the OAuth 2.0 Authorization Code documentation for the full list.
A Productboard user can authorize any scopes on the application, but their role permissions are enforced when the application calls the API on their behalf.
Best Practices
- Always use S256. The
plainchallenge method is not supported. - Generate a new code verifier for each authorization request. Never reuse verifiers.
- Store the code verifier securely until the token exchange is complete, then discard it.
- Use the
stateparameter to prevent CSRF attacks and maintain application state. - Request only the scopes you need. Users see the requested permissions on the consent screen.
- Handle token refresh gracefully. Refresh the access token before it expires to avoid interruptions.
