Injecting API Keys Safely into Docs Consoles
This guide is part of Interactive API Consoles & Try-It Panels within Developer Portal Frameworks & UI Setup. It covers where a try-it console’s credential comes from and where it is allowed to live. The problem arrives the moment a console is genuinely useful: readers want to call something real, and the fastest way to let them is also the way that publishes a credential to everyone who ever loads the page.
Problem & Context
A try-it console needs a credential to do anything interesting, and every obvious way of supplying one is wrong in a specific way.
Putting a key in the page source is public. Not “discoverable by a determined attacker” — public, in the same sense the page text is public, and it stays public in CDN caches, in the Wayback Machine, and in every fork of the docs repository long after someone quietly deletes the line. Removing it does nothing; only rotating it at the source does.
Asking readers to paste their own key works, and then most consoles helpfully write it to localStorage so they do not have to paste it again. That single convenience turns a transient secret into a persistent one, readable by any script that ever runs on the origin, and reliably captured in the screenshots people attach to support tickets.
Giving the console nothing at all is safe and useless: every operation returns 401, and the reader concludes the documentation is broken rather than that they need to authenticate.
The way out is to stop treating this as one problem. A sandbox and a real account have completely different requirements, and solving each separately is much easier than finding one credential that suits both.
Step-by-Step Solution
1. Classify the credential you need
Three cases, three answers:
Sandbox, disposable data -> server-side demo token, injected by the proxy
Real account, real data -> short-lived token minted for the session
Destructive or unreleased -> no credential; point the console at a mock
The third row is the one teams forget. An endpoint that cannot be called safely does not need a safer credential — it needs a mock server behind the console.
2. Inject the sandbox credential server-side
The demo token lives in the proxy’s environment and is attached on the way out, so nothing reaches the browser:
// inside the proxy, after resolving the upstream
if (envName === 'sandbox') {
headers.set('authorization', `Bearer ${env.SANDBOX_DEMO_TOKEN}`);
}
The reader gets a working console with no signup, and the credential is a server-side secret like any other. See Proxying CORS for API try-it consoles for the proxy this drops into.
3. Mint short-lived tokens for real accounts
When a signed-in reader wants to call production, exchange their existing docs session for a narrowly scoped, short-lived token rather than showing them a long-lived key to copy:
// POST /api/console-token — returns a token scoped to the reader, valid 15 minutes
export async function onRequestPost({ request, env }) {
const session = await verifyDocsSession(request, env); // your existing auth
if (!session) return new Response('unauthorized', { status: 401 });
const token = await mintToken(env, {
sub: session.userId,
scope: ['invoices:read', 'invoices:write'], // never a full-access scope
aud: 'console', // distinguishable in audit logs
exp: Math.floor(Date.now() / 1000) + 900, // 15 minutes
});
return new Response(JSON.stringify({ token, expires_in: 900 }), {
headers: { 'content-type': 'application/json', 'cache-control': 'no-store' },
});
}
The aud: 'console' claim is worth the line it costs: when something odd shows up in an audit log, you can tell immediately whether it came from a documentation console or from a real integration.
4. Keep reader-supplied tokens in memory only
Whatever the reader pastes stays in a JavaScript variable for the life of the tab and nowhere else:
const configuration = {
persistAuth: false, // Scalar
// persistAuthorization: false, // Swagger UI equivalent
};
Then assert it, because this default changes between versions:
// Should return []. Anything else is a finding.
Object.keys(localStorage).filter((k) => /auth|token|key|bearer/i.test(k));
5. Rotate and monitor
A demo credential that never rotates is a long-lived secret sitting in front of an unauthenticated gateway. Rotate on a schedule, and alert when proxy volume for the sandbox leaves its normal band — that is what an abused console looks like from the outside.
Complete Working Example
A client-side credential manager that holds a token in memory, renews it before expiry, and refuses to persist anything:
// console-credential.js — no storage, no globals, renews silently.
export function createCredentialManager({ mintUrl = '/api/console-token' } = {}) {
let token = null;
let expiresAt = 0;
let inflight = null;
async function mint() {
const res = await fetch(mintUrl, { method: 'POST', credentials: 'same-origin' });
if (res.status === 401) { token = null; expiresAt = 0; return null; }
if (!res.ok) throw new Error(`token mint failed: ${res.status}`);
const { token: t, expires_in } = await res.json();
token = t;
// Renew a minute early so a request never fails on a just-expired token.
expiresAt = Date.now() + (expires_in - 60) * 1000;
return token;
}
return {
async get() {
if (token && Date.now() < expiresAt) return token;
// Collapse concurrent renewals into one request.
inflight = inflight || mint().finally(() => { inflight = null; });
return inflight;
},
clear() { token = null; expiresAt = 0; },
};
}
// Wire it into the console's request interceptor.
const creds = createCredentialManager();
export const configuration = {
url: '/openapi/api.bundled.yaml',
persistAuth: false,
proxyUrl: '/api-proxy',
onBeforeRequest: async ({ request }) => {
// Sandbox needs nothing — the proxy attaches the demo token server-side.
if (!request.url.includes('env=production')) return request;
const t = await creds.get();
if (t) request.headers.set('authorization', `Bearer ${t}`);
return request;
},
};
// Drop the token the moment the reader navigates away or signs out.
window.addEventListener('pagehide', () => creds.clear());
Verify the invariants hold:
node -e "
const { chromium } = require('playwright');
(async () => {
const b = await chromium.launch();
const p = await b.newPage();
await p.goto('https://docs.example.com/reference/');
await p.getByRole('button', { name: /send/i }).first().click();
await p.waitForSelector('[data-testid=\"response-status\"]');
const stored = await p.evaluate(() => [...Object.keys(localStorage), ...Object.keys(sessionStorage)]);
const leaked = stored.filter(k => /auth|token|key|bearer/i.test(k));
console.log(leaked.length === 0 ? 'OK nothing persisted' : 'FAIL ' + leaked.join(','));
await b.close();
process.exit(leaked.length === 0 ? 0 : 1);
})();
"
OK nothing persisted
Gotchas & Edge Cases
The demo token expires and the console looks broken. Because the credential is injected server-side, no reader action can fix an expired one, and the symptom is a blanket 401 that reads as a documentation defect. Monitor the demo token’s expiry as infrastructure, and have the proxy return a distinguishable error body when its own credential is rejected rather than passing the upstream 401 through unchanged.
A scoped token still needs a scoped account. Minting a token with invoices:read from a session that has full administrative rights limits the token, not the blast radius of the endpoint it calls. Confirm the scope is enforced by the API, not merely present in the claim.
Renewal storms. A console that mints a token per request rather than per session will hammer the mint endpoint, and a naive implementation renews concurrently from every in-flight call. The inflight promise above collapses them; without it, a page with several panels open mints several tokens at once.
Sign-out does not reach the console. If a reader signs out in another tab, the console keeps its in-memory token until it expires. That is usually acceptable given a fifteen-minute lifetime, but if it is not, listen for a storage event or a broadcast channel message and call clear().
Rotation without a rollover window causes an outage. Replacing the demo token in one step means every in-flight request fails. Support two valid tokens briefly — mint the new one, deploy it, then revoke the old — the same way you would rotate any other production credential.
FAQ
Can I just put a demo key in the page?
Only if that key is genuinely worthless — scoped to a sandbox with disposable data, read-only, and rate-limited. Anything else in page source is public the moment it deploys, and stays public in CDN copies and archives long after you remove it. Rotation, not deletion, is the remedy.
Why not store the reader’s token in localStorage for convenience?
Because it survives the tab, is readable by any script on the origin, and gets captured in screenshots attached to bug reports. The convenience saves one paste; the exposure lasts until the token expires, which for a long-lived key is never.
How short should a minted token be?
Minutes, not hours. Long enough to explore a few endpoints and short enough that a leaked screenshot is a non-event. Fifteen minutes with silent renewal while the tab is open works well in practice, and the renewal is invisible to the reader.
One more reason the split matters: the two credential paths fail in opposite directions, and knowing which you are on tells you who can fix a reported problem. A sandbox failure is always yours; a production failure is almost always the reader’s session. Keeping the two visibly distinct in the console — a different label, a different response header — turns a vague report into a routed one.
Related
- Interactive API Consoles & Try-It Panels — the parent guide and the credential-placement decision.
- Proxying CORS for API try-it consoles — where the sandbox credential is attached.
- Adding a try-it console with Scalar — the console configuration that disables persistence.
- Mock Servers & Contract Testing from OpenAPI — the no-credential option for risky endpoints.