PortModels
Log in

PKCE for public clients

The authorization code flow without a client secret, for desktop and browser-only apps.

For apps that can't hold a secret — desktop apps, or a browser-only client with no backend. Standard OAuth PKCE, S256 only.

1. Generate a verifier and challenge#

Generate once per attempt, before redirecting the user:

js
const verifier = base64url(crypto.getRandomValues(new Uint8Array(32)));
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier));
const challenge = base64url(new Uint8Array(digest));
sessionStorage.setItem('pm_verifier', verifier); // read back after the redirect

function base64url(bytes) {
  return btoa(String.fromCharCode(...bytes))
    .replace(/\+/g, '-')
    .replace(/\//g, '_')
    .replace(/=+$/, '');
}

2. Redirect with the challenge#

js
const url = new URL('https://app.portmodels.com/connect/authorize');
url.searchParams.set('client_id', 'acme/chatbot');
url.searchParams.set('redirect_uri', 'https://yourapp.com/callback');
url.searchParams.set('scope', 'models.run kv.read');
url.searchParams.set('code_challenge', challenge);
url.searchParams.set('code_challenge_method', 'S256');
location.href = url.toString();

code_challenge is required on the authorize call for public clients — omitting it is rejected before a code is ever minted.

3. Exchange with the verifier#

No client_secret. The code_verifier proves you started the flow:

bash
curl -X POST https://api.portmodels.com/connect/token \
  -H "Content-Type: application/json" \
  -d '{
    "grant_type": "authorization_code",
    "client_id": "acme/chatbot",
    "code": "pmac_...",
    "code_verifier": "'"$PM_VERIFIER"'",
    "redirect_uri": "https://yourapp.com/callback"
  }'

The response is the same shape as the confidential flow:

json
{
  "access_token": "pmc_...",
  "token_type": "bearer",
  "scope": "models.run kv.read",
  "connection_id": "..."
}

Each exchange creates a separate token for that browser or desktop installation. Running PKCE again on another device does not invalidate a token you already issued.

Desktop apps#

A desktop app opens the system browser and listens on a loopback redirect URI:

  1. Bind a local HTTP server on a port you registered, e.g. http://localhost:5173/callback.
  2. Open the authorize URL in the user's browser.
  3. Capture code from the loopback request, exchange it, and shut the local server down.

Register the exact loopback URI in the console — matching is exact, so http://localhost:5173/callback and http://127.0.0.1:5173/callback are different URIs.

If your app cannot open a browser at all, use the device flow.

Common mistakes#

SymptomCause
404 at /connect/authorizeThe browser endpoint is on app.portmodels.com; use api.portmodels.com for token and device API calls
invalid_grant at exchangeVerifier mismatch, exact redirect mismatch, an expired or already-used code, or a second exchange attempt
Rejected at authorizePublic client sent no code_challenge
Redirect mismatchThe URI at exchange differs from the one at authorize
Works once, then failsCodes are single-use; generate a fresh verifier per attempt

If a fresh code fails on its first exchange after you have independently verified the verifier/challenge pair and the exact redirect URI, keep the timestamp and response body and contact support. Do not send the verifier, client secret, or access token; the code should not be retried because each code is single-use.