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:
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#
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:
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:
{
"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:
- Bind a local HTTP server on a port you registered, e.g.
http://localhost:5173/callback. - Open the authorize URL in the user's browser.
- Capture
codefrom 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#
| Symptom | Cause |
|---|---|
404 at /connect/authorize | The browser endpoint is on app.portmodels.com; use api.portmodels.com for token and device API calls |
invalid_grant at exchange | Verifier mismatch, exact redirect mismatch, an expired or already-used code, or a second exchange attempt |
| Rejected at authorize | Public client sent no code_challenge |
| Redirect mismatch | The URI at exchange differs from the one at authorize |
| Works once, then fails | Codes 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.