PKCE (Proof Key for Code Exchange) secures the OAuth2 flow for applications that cannot store a client_secret — such as single-page apps (React, Vue, Angular), native mobile apps, and client-side JavaScript. Instead of a client secret, you use a one-time code_verifier and its code_challenge to prove you initiated the authorization request.
Prerequisites
Before you implement this flow, ensure you have:
- OAuth2 client — Register your app in Client Center to get
client_id (no client_secret required for PKCE)
- Redirect URI — Must be pre-registered in Client Center; use the exact URL where your app handles the callback (e.g.,
https://yourapp.com/oauth/callback)
- Runtime support — Browser with
crypto.subtle or Node.js 18+ / Python 3.8+ for generating code_verifier and code_challenge
- Storage for code_verifier — Store the
code_verifier between the redirect and callback (e.g., sessionStorage in browser, secure storage in mobile apps)
PKCE is recommended for all public clients (SPAs, mobile apps). Even if your backend eventually handles the token exchange, starting with PKCE improves security.
How PKCE works
- Generate a random
code_verifier (43–128 characters).
- Create a
code_challenge = Base64URL(SHA256(code_verifier)).
- Include
code_challenge and code_challenge_method=S256 in the authorization URL.
- Store
code_verifier until the callback.
- When exchanging the code for tokens, send
code_verifier instead of client_secret. Aries verifies it matches the challenge.
Step 1: Generate code_verifier, code_challenge, and redirect
Generate a cryptographically random code_verifier, derive the code_challenge, and redirect the user to Aries. Store the code_verifier so you can send it when exchanging the code.
Endpoint: https://app.aries.com/oauth2/authorize
Query parameters (including PKCE):
| Parameter | Description |
|---|
response_type | code |
client_id | Your OAuth2 client ID |
redirect_uri | Must match a registered URI |
scope | Space-separated scopes |
state | Random string for CSRF protection |
code_challenge | Base64URL(SHA256(code_verifier)) |
code_challenge_method | S256 |
const crypto = require('crypto');
// Generate code_verifier (43–128 chars, URL-safe)
const codeVerifier = crypto.randomBytes(32).toString('base64url');
// Derive code_challenge (SHA256, Base64URL)
const codeChallenge = crypto
.createHash('sha256')
.update(codeVerifier)
.digest('base64url');
const state = crypto.randomBytes(32).toString('hex');
// Store code_verifier in session for callback (e.g., req.session.pkce_code_verifier = codeVerifier)
const params = new URLSearchParams({
response_type: 'code',
client_id: process.env.ARIES_CLIENT_ID,
redirect_uri: process.env.ARIES_REDIRECT_URI,
scope: 'account:information order:execution market:information',
state,
code_challenge: codeChallenge,
code_challenge_method: 'S256',
});
const authUrl = `https://app.aries.com/oauth2/authorize?${params}`;
res.redirect(authUrl);
Browser (SPA) example — use sessionStorage to persist code_verifier across the redirect:
async function generateCodeChallenge() {
const codeVerifier = crypto.randomUUID() + crypto.randomUUID();
sessionStorage.setItem('pkce_code_verifier', codeVerifier);
const encoder = new TextEncoder();
const data = encoder.encode(codeVerifier);
const hash = await crypto.subtle.digest('SHA-256', data);
const codeChallenge = btoa(String.fromCharCode(...new Uint8Array(hash)))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
const state = crypto.randomUUID();
sessionStorage.setItem('oauth_state', state);
const params = new URLSearchParams({
response_type: 'code',
client_id: 'YOUR_CLIENT_ID',
redirect_uri: window.location.origin + '/oauth/callback',
scope: 'account:information',
state,
code_challenge: codeChallenge,
code_challenge_method: 'S256',
});
window.location.href = `https://app.aries.com/oauth2/authorize?${params}`;
}
Step 2: Handle the callback
After the user approves, Aries redirects to your redirect_uri with the authorization code and state. Retrieve the stored code_verifier and exchange the code for tokens. Do not use the code twice — exchange it once and immediately.
app.get('/oauth/callback', async (req, res) => {
if (req.query.state !== req.session.oauth_state) {
return res.status(400).send('Invalid state parameter');
}
if (req.query.error) {
return res.status(400).send(`Authorization error: ${req.query.error}`);
}
const authCode = req.query.code;
const codeVerifier = req.session.pkce_code_verifier;
if (!authCode || !codeVerifier) {
return res.status(400).send('Missing code or code_verifier');
}
try {
const tokens = await exchangeCodeWithPKCE(authCode, codeVerifier);
req.session.access_token = tokens.access_token;
req.session.refresh_token = tokens.refresh_token;
res.redirect('/dashboard');
} catch (err) {
res.status(500).send('Token exchange failed');
}
});
Browser (SPA) callback — exchange happens from the client:
async function handleCallback() {
const params = new URLSearchParams(window.location.search);
if (params.get('state') !== sessionStorage.getItem('oauth_state')) {
throw new Error('Invalid state');
}
if (params.get('error')) {
throw new Error(params.get('error_description') || params.get('error'));
}
const code = params.get('code');
const codeVerifier = sessionStorage.getItem('pkce_code_verifier');
sessionStorage.removeItem('pkce_code_verifier');
sessionStorage.removeItem('oauth_state');
const tokens = await exchangeCodeWithPKCE(code, codeVerifier);
sessionStorage.setItem('access_token', tokens.access_token);
sessionStorage.setItem('refresh_token', tokens.refresh_token);
window.location.href = '/dashboard';
}
Step 3: Exchange the code for tokens (with code_verifier)
Send the authorization code and code_verifier to the token endpoint. Do not include client_secret — PKCE uses code_verifier instead.
Endpoint: POST https://api.aries.com/v1/oauth2/token
Request body (PKCE):
| Field | Required | Description |
|---|
client_id | Yes | Your OAuth2 client ID |
grant_type | Yes | code |
code | Yes | The authorization code from the callback |
redirect_uri | Yes | Must match the redirect URI used in Step 1 |
code_verifier | Yes | The original random value (not the hash) you sent as code_challenge |
# Replace AUTHORIZATION_CODE and CODE_VERIFIER with values from your flow
curl -X POST 'https://api.aries.com/v1/oauth2/token' \
-H 'Content-Type: application/json' \
-d '{
"client_id": "YOUR_CLIENT_ID",
"grant_type": "code",
"code": "AUTHORIZATION_CODE",
"redirect_uri": "YOUR_REDIRECT_URI",
"code_verifier": "YOUR_CODE_VERIFIER"
}'
Response (same as Authorization Code flow):
{
"access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "eyJjdHkiOiJKV1QiLCJlbmMiOiJBMjU2R0NNIiwiYWxnIjoiUlNBLU9BRVAifQ...",
"scope": "account:information order:execution market:information"
}
Step 4: Make authenticated API requests
Use the access token in the Authorization header for every API request.
curl -X GET 'https://api.aries.com/v1/users/me' \
-H 'Authorization: Bearer YOUR_ACCESS_TOKEN'
Step 5: Refresh the access token
Access tokens expire after expires_in seconds (typically 1 hour). Use the refresh token to get a new access token. For public clients (SPAs, mobile apps without a backend), refresh typically requires client_secret. If your PKCE app has no backend, consider using a Backend-for-Frontend (BFF) to perform refresh, or prompt the user to re-authenticate when the access token expires.
Endpoint: POST https://api.aries.com/v1/oauth2/token
If you have a backend (e.g., BFF or server-side PKCE), use the same refresh format as the Authorization Code flow:
curl -X POST 'https://api.aries.com/v1/oauth2/token' \
-H 'Content-Type: application/json' \
-d '{
"client_id": "YOUR_CLIENT_ID",
"client_secret": "YOUR_CLIENT_SECRET",
"grant_type": "refresh_token",
"refresh_token": "YOUR_REFRESH_TOKEN",
"redirect_uri": "YOUR_REDIRECT_URI"
}'
Complete SPA example (React)
Minimal React example for a client-side PKCE flow:
// Login: generate challenge and redirect
async function login() {
const codeVerifier = crypto.randomUUID().replace(/-/g, '') + crypto.randomUUID().replace(/-/g, '');
sessionStorage.setItem('pkce_code_verifier', codeVerifier);
const encoder = new TextEncoder();
const hash = await crypto.subtle.digest('SHA-256', encoder.encode(codeVerifier));
const codeChallenge = btoa(String.fromCharCode(...new Uint8Array(hash)))
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
const state = crypto.randomUUID();
sessionStorage.setItem('oauth_state', state);
const params = new URLSearchParams({
response_type: 'code',
client_id: 'YOUR_CLIENT_ID',
redirect_uri: `${window.location.origin}/callback`,
scope: 'user:information account:information',
state,
code_challenge: codeChallenge,
code_challenge_method: 'S256',
});
window.location.href = `https://app.aries.com/oauth2/authorize?${params}`;
}
// Callback: exchange code for tokens
async function handleCallback() {
const params = new URLSearchParams(window.location.search);
if (params.get('state') !== sessionStorage.getItem('oauth_state')) {
alert('Invalid state');
return;
}
const code = params.get('code');
const codeVerifier = sessionStorage.getItem('pkce_code_verifier');
const res = await fetch('https://api.aries.com/v1/oauth2/token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
client_id: 'YOUR_CLIENT_ID',
grant_type: 'code',
code, redirect_uri: `${window.location.origin}/callback`,
code_verifier: codeVerifier,
}),
});
const tokens = await res.json();
sessionStorage.setItem('access_token', tokens.access_token);
sessionStorage.removeItem('pkce_code_verifier');
sessionStorage.removeItem('oauth_state');
window.location.href = '/dashboard';
}
Next steps