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, 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)
- Discover the authorization server from the MCP server's metadata.
- 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 signs in and 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.
- Audience-bound tokens: Access tokens are issued for the MCP server,
https://mcp.productboard.com(RFC 8707 resource indicator).
Endpoints and Discovery
All MCP OAuth endpoints live on https://mcp.productboard.com, which serves workspaces hosted in the US region.
| Endpoint | URL |
|---|---|
| Protected Resource Metadata (RFC 9728) | GET https://mcp.productboard.com/.well-known/oauth-protected-resource |
| Authorization Server Metadata (RFC 8414) | GET https://mcp.productboard.com/.well-known/oauth-authorization-server |
| Dynamic Client Registration (RFC 7591) | POST https://mcp.productboard.com/oauth2/register |
| Authorization | GET https://mcp.productboard.com/oauth2/authorize |
| Token | POST https://mcp.productboard.com/oauth2/token |
The metadata and registration endpoints exist only on mcp.productboard.com. The authorization endpoint redirects the browser to https://app.productboard.com/oauth2/authorize, where the user signs in and sees the consent screen. The authorization and token endpoints are also served on app.productboard.com, but use the URLs advertised in the metadata.
Discovery flow:
-
A request to the MCP server without a valid access token returns HTTP
401with the headerWWW-Authenticate: Bearer resource_metadata="https://mcp.productboard.com/.well-known/oauth-protected-resource". -
The protected resource metadata names the authorization server:
{ "resource": "https://mcp.productboard.com", "authorization_servers": ["https://mcp.productboard.com"], "bearer_methods_supported": ["header"] } -
The authorization server metadata lists the endpoints and supported features:
{ "issuer": "https://mcp.productboard.com", "authorization_endpoint": "https://mcp.productboard.com/oauth2/authorize", "token_endpoint": "https://mcp.productboard.com/oauth2/token", "registration_endpoint": "https://mcp.productboard.com/oauth2/register", "response_types_supported": ["code"], "grant_types_supported": ["authorization_code", "refresh_token"], "code_challenge_methods_supported": ["S256"], "token_endpoint_auth_methods_supported": ["none", "client_secret_basic", "client_secret_post"], "resource_parameter_supported": true }client_secret_basicandclient_secret_postare listed so that clients which default to them can register. Registration always creates a public client and returnstoken_endpoint_auth_method: "none", so authenticate at the token endpoint with yourclient_idand PKCE only.
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://mcp.productboard.com/oauth2/register
Content-Type: application/json
Request body:
| Parameter | Description | Required |
|---|---|---|
redirect_uris | Array of one or more redirect URIs. Each must be an https URI, an http loopback URI (localhost, 127.0.0.1, or [::1], any port), or a private-use URI scheme for native apps (for example, cursor://…). Wildcards and schemes such as javascript, data, or file are rejected. | Yes |
client_name | Human-readable name of the application. Shown to users on the consent screen. If omitted, Productboard generates a name such as Dynamic Client 1a2b3c4d. | No (recommended) |
token_endpoint_auth_method | none, client_secret_basic, or client_secret_post. Defaults to none. Whatever you send, the client is registered as a public client, no secret is issued, and the response returns none. | No |
client_uri, logo_uri, tos_uri, policy_uri, software_id, software_version, contacts | Optional RFC 7591 client metadata. Stored and returned in the response. | No |
Any other fields, including scope, grant_types, and response_types, are ignored. See Access Scopes for how scopes are assigned.
Example request:
curl -X POST "https://mcp.productboard.com/oauth2/register" \
-H "Content-Type: application/json" \
-d '{
"client_name": "AwesomeMCP",
"redirect_uris": ["http://localhost:8080/callback"],
"token_endpoint_auth_method": "none"
}'Successful response (201):
{
"client_id": "hqAGVdAy9FZX5Ky5cBHUB2FshBdSO6eN75tWMX46ZZd",
"client_id_issued_at": 1755600000,
"client_name": "AwesomeMCP",
"redirect_uris": ["http://localhost:8080/callback"],
"grant_types": ["authorization_code", "refresh_token"],
"response_types": ["code"],
"token_endpoint_auth_method": "none",
"resource": "https://mcp.productboard.com"
}Persist the returned client_id. No client secret is issued — public clients use PKCE in place of a secret.
- Re-registering is safe. Registering again with the same
client_name,redirect_uris(in the same order), andsoftware_idreturns the existing client instead of creating a new one. The response is still HTTP201, with the originalclient_idandclient_id_issued_atand the metadata stored at first registration: changed fields such aslogo_uriorclient_uriaren't updated. This only works when you send aclient_name. - Unused registrations are cleaned up. If a registered client has no unrevoked tokens, it is deleted 7 days after it was last registered. If the authorization endpoint reports
Invalid client_id., register again.
Rate limits: Registration is rate-limited to 60 requests per minute and 500 requests per day per remote IP. Exceeding a limit returns HTTP
429with aRetry-Afterheader (in seconds). Register once per installation and cache theclient_id, rather than registering on every run.
Registration errors
Validation errors return HTTP 400:
| Error code | When it happens |
|---|---|
invalid_redirect_uri | redirect_uris is missing or empty, or a URI is malformed, contains a wildcard, uses a forbidden scheme, or uses http on a non-loopback host. |
invalid_client_metadata | Another field failed validation (for example, an unsupported token_endpoint_auth_method). |
The response also includes an error_description field with a human-readable explanation.
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's browser to the authorization endpoint, https://mcp.productboard.com/oauth2/authorize, with the following query parameters. Productboard redirects the browser to https://app.productboard.com/oauth2/authorize, where the user signs in and approves access on the consent screen.
| 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 |
| resource | The MCP server's resource identifier, https://mcp.productboard.com (RFC 8707). If sent, it must match (a trailing slash is ignored). If omitted, it defaults to the same value. | No |
| state | An arbitrary string returned unchanged in the response. Recommended for CSRF prevention. | No |
| scope | Ignored. Productboard assigns the token's scopes itself — see Access Scopes. | No |
Example authorization URL:
https://mcp.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
&resource=https%3A%2F%2Fmcp.productboard.com
&state=abc123
Important: If you omit
code_challengeor use any method other thanS256, Productboard shows an authorization error instead of the consent screen. See Authorization errors.
To complete authorization, the user must be an active member of the workspace, the workspace must have MCP turned on, and the user's role must be allowed to use MCP (if the admin hasn't selected any roles, every role is allowed). Workspace admins control both settings — see Connecting your agent.
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://mcp.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 |
| resource | If sent, must be https://mcp.productboard.com | No |
Note: No
client_secretis needed. The client authenticates with itsclient_idalone (token_endpoint_auth_method: none), and thecode_verifierproves you are the same client that initiated the authorization request.
Example request:
curl -X POST "https://mcp.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": "identity:read documents:read entities:read members:read teams:read",
"created_at": 1692288000
}| Field | Description |
|---|---|
| access_token | Bearer token (JWT format) for requests to the MCP server, https://mcp.productboard.com |
| 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 scopes Productboard assigned, which depend on the user's role — see Access Scopes |
| created_at | UNIX timestamp of token creation |
Send the access token in the Authorization: Bearer YOUR_ACCESS_TOKEN header of your requests to the MCP server.
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://mcp.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 |
| resource | If sent, must be https://mcp.productboard.com | No |
Example request:
curl -X POST "https://mcp.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. Store the new refresh token right away: the one you just used stays valid for only 10 minutes, giving you time to retry if you experience network issues.
Warning: If you lose both the access and refresh tokens, or a workspace admin disconnects all MCP clients, the user must re-authorize your application.
Error Handling
Authorization errors
When an authorization request can't proceed, Productboard shows the user an Authorization error page (HTTP 401) instead of the consent screen. Your redirect_uri is not called.
| Cause | Message shown |
|---|---|
Missing code_challenge, or code_challenge_method other than S256 | "PKCE code_challenge is required for this application. Use S256 method." |
Unknown or deleted client_id | "Invalid client_id." |
| The user isn't an active member of the workspace they signed in to | "Your account is not an active member of this workspace. Sign in to the workspace you want to authorize and try again." |
| MCP is turned off for the workspace, or the user's role isn't allowed to use MCP | "MCP access is not enabled for this workspace, or your role is not allowed to authorize MCP applications." |
These errors are returned to your redirect_uri instead:
| Error | When it happens |
|---|---|
invalid_target | The resource parameter doesn't match https://mcp.productboard.com, or is sent in array form (resource[]=…). Productboard checks it when the user approves, so this redirect comes after the consent screen. The redirect includes error, error_description, and state. |
access_denied | The user selected Deny on the consent screen. |
Token exchange errors
| Error | When it happens |
|---|---|
invalid_grant | The authorization code is invalid, expired, or already used, or the refresh token is invalid, expired, or revoked. |
invalid_grant | The code_verifier does not match the code_challenge sent during authorization. |
invalid_target | The resource parameter doesn't match https://mcp.productboard.com (HTTP 400). |
invalid_client | The client_id is unknown, or the request included a client_secret — public clients have none (HTTP 401). |
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 10 minutes after being used) | Refresh token / New authorization request |
| Client registration | Deleted 7 days after it was last registered, if it has no unrevoked tokens | Register again |
Access Scopes
MCP clients don't choose scopes. Productboard ignores scope at registration and at the authorization endpoint, and gives each MCP access token the scopes of the signed-in user's role. Custom roles get the scopes of the role they're based on.
| Role | Scopes |
|---|---|
| Maker with admin access, Maker | identity:read, documents:read, documents:write, entities:read, entities:write, fields:write, notes:read, notes:write, members:read, members:pii:read, teams:read, users:read, users:pii:read, companies:read |
| Contributor | Same as Maker, without fields:write and entities:write |
| Viewer | identity:read, documents:read, entities:read, members:read, teams:read |
members:pii:read and users:pii:read cover names and email addresses of teammates and end users.
Scopes are an upper limit, not a grant. Within them, what your client can see and do is still determined by the signed-in user's role permissions in Productboard and by the workspace's MCP settings. MCP tools that need a scope the token doesn't carry may be left out of tools/list or refused. The scopes per role can change as the MCP server gains tools, so read scope from the token response rather than hard-coding it. The consent screen lists what the client will be able to do on the user's behalf.
Best Practices
- Discover endpoints from the metadata. Use the URLs in
https://mcp.productboard.com/.well-known/oauth-authorization-serverrather than hard-coding hosts. - Register once and cache the
client_id. Registration is rate-limited; re-registering the same client returns the sameclient_id. - 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. - Send the
resourceparameter (https://mcp.productboard.com) on authorization and token requests. - Handle token refresh gracefully. Refresh the access token before it expires, and always store the newest refresh token.
