Token rotation assumes your clients can write
Choose credential mechanics based on whether your callers can persist new state at runtime — and offer a static-token tier plus an optional proof-of-possession tier instead of rotation your ecosystem can't implement.
Rotating or refresh-token schemes have a hidden system requirement that textbook security guidance never states: the client must be able to durably persist a new secret at runtime. A huge class of real deployment targets can’t. Streamlit Cloud apps, Vercel and Netlify deployments, Docker containers, CI jobs — their configuration is environment variables, read-only from the process’s point of view, changed only by a human through a dashboard and a redeploy. Hand such an app a refresh token and you’ve built a system where every rotation either fails (the app can’t store the new token) or forces the developer to bolt on a database just to hold your credential. The security upgrade becomes an adoption tax.
I hit this concretely with Cookey, a self-hosted gateway that grants third-party apps controlled access to the owner’s API keys. The first version required every connecting app to embed an Ed25519 signing SDK — and the migration spec is blunt about the result: the flagship integration “stalled precisely here.” A protocol that’s more secure on paper and unadoptable in practice protects nothing.
Start from what the caller can do
The redesign starts from the caller’s constraints instead of the security wishlist, and splits authentication into two tiers:
- Static bearer tokens (
ck_…) are the default. One value in an env var, works with any HTTP client — including an unmodified OpenAI SDK pointed at the gateway with the token as its API key. Zero code, zero storage requirements beyond what every deployment platform already has. The token never changes for the life of the grant, and its lifetime is bounded at mint instead of managed by rotation. - Proof-of-possession (PoP) is the opt-in tier for long-lived grants. The app holds an Ed25519 private key (also just an env var — the key never changes either, so read-only config still suffices) and signs each request. Compromise of a logged or leaked request doesn’t yield a reusable credential.
The spec’s rejected-alternatives list records the decision so it stays decided: rotation “requires the target app to persist newly issued tokens at runtime; the target-app ecosystem (Streamlit Cloud, Vercel env vars, Docker env) is read-only-config at runtime, making this impractical. Long-lived grants use PoP instead.” A second rejection is the same philosophy from another angle: no grant field where an app promises to store the token hashed — “rejected as unverifiable theater. Security comes from gateway-side containment and detection, never from counterparty promises.”
That’s the boundary in one sentence: everything that makes a static token safe must live on the side that can actually change state — the gateway.
Containment instead of rotation
If rotation is off the table, the token’s blast radius has to be bounded by other means, all server-side. In Cookey these are, concretely:
Lifetime is decided at mint, not managed afterward. The token expiry is derived from what the owner approved (apps/proxy/src/server/grants/tokens.ts):
/**
* Token expiry rule: min(grant expiry, current renewal period end).
*/
export function computeTokenExpiry(grant: {
expiresAt: Date | null;
currentPeriodEnd: Date | null;
}): Date {
const candidates = [grant.expiresAt, grant.currentPeriodEnd].filter(
(d): d is Date => d !== null,
);
if (candidates.length === 0) {
return new Date(Date.now() + 10 * 365 * 24 * 60 * 60 * 1000); // safety net
}
return new Date(Math.min(...candidates.map((d) => d.getTime())));
}
Renewable grants get tokens that die at the end of the current period; the owner renewing the grant is the “rotation,” performed by the party that can perform it, on their own dashboard.
The gateway stores only a hash, and generates the token honestly:
export function generateTokenString(): string {
let out = "";
while (out.length < TOKEN_RANDOM_LENGTH) {
// Rejection sampling to avoid modulo bias: 62 * 4 = 248 <= 256
const bytes = randomBytes(TOKEN_RANDOM_LENGTH);
for (const byte of bytes) {
if (byte < 248 && out.length < TOKEN_RANDOM_LENGTH) {
out += BASE62[byte % 62];
}
}
}
return TOKEN_PREFIX + out;
}
export function hashToken(token: string): string {
return createHash("sha256").update(token).digest("hex");
}
40 base62 characters ≈ 238 bits of entropy; auth is a lookup by exact SHA-256 hash, timing-safe by construction and requiring no per-request bcrypt cost (the input is high-entropy, so fast hashing is fine — this rule does not transfer to passwords).
Detection and containment substitute for rotation’s damage-limiting: every token carries lastUsedAt/lastUsedIp tracking with change-detection, grants can pin egress IPs, budgets cap worst-case spend, and revocation is a single updateMany setting revokedAt — instant, owner-side, no client cooperation needed.
For a reader adapting the two-tier shape: the fork belongs in your auth resolver — try the bearer prefix first, fall through to signature headers — with both paths converging on the same grant/permission checks so the tiers differ only in how identity is proven, never in what it’s allowed to do.
The failure you accept, the one you refuse
The failure this design accepts is that a leaked bearer token is replayable until expiry or revocation — that’s the price of zero-code adoption, and it’s why the caps and detection exist: the worst case of a leak is bounded spend on scoped resources, visible in usage logs, killable in one click. The failure it refuses to accept is the rotation scheme’s: a fleet of integrations that silently break every rotation interval because their platforms can’t persist the new secret, training developers to work around your security mechanism. And when an app genuinely outgrows bearer risk, the escalation path is PoP — which asks the client only for something read-only config can provide: the same key, forever, never transmitted.
Onboarding becomes an env var
Integration support stops being cryptography support: the default onboarding instruction is “put this value in an env var,” identical to every API your users have already integrated. Security review shifts from auditing N apps’ token-storage promises — which you could never verify anyway — to auditing one gateway’s containment: expiry derivation, revocation latency, budget enforcement, anomaly surfacing. And your roadmap gains a crisp gate for the fancy tier: PoP earns its complexity only for grants whose lifetime makes static-token exposure unacceptable, instead of being the toll every integration pays at the door.