- AI APIs
- Privacy
- Chrome Web Store
How to hide an API key in a Chrome extension: a backend proxy for OpenAI, Claude and Gemini calls
You cannot hide an API key in a Chrome extension's code. Why, what 2026 research found, and how to build a small proxy with sign-in, quotas and usage metering.
- Author
- Extenify Team
- Published
- Reading time
- 12 min read

On this page
"How do I hide my API key in a Chrome extension?" is one of the oldest extension questions on Stack Overflow, and the AI boom has made it expensive to get wrong. If you want to hide an API key in a Chrome extension, the honest answer is that you cannot: anything you ship in the package can be read by anyone who installs it. What you can do is keep the key off the client entirely and give the extension something less valuable to hold.
This guide explains why bundling, environment variables and obfuscation do not work, what recent research found in real extensions, and the three patterns that do work. Then it builds the most common one, a small backend proxy for an AI feature, with sign-in, a fixed task, per-user quotas and token metering, in about 120 lines of Node.js.
Key takeaways
- A Chrome extension is a package anyone can download and unzip. Any key inside it, including one injected from a
.envfile at build time, is public.- Obfuscating the key is not an option: the Chrome Web Store rejects obfuscated code, and a determined user can still read the request in DevTools.
- The safe patterns are a backend proxy that holds the key, letting users bring their own key, or short-lived tokens minted by your backend where the provider supports them.
- A proxy must authenticate users, expose fixed tasks instead of a raw relay, and enforce quotas, or it becomes a free AI API for strangers.
- Gemini now rejects unrestricted standard keys, and all new AI Studio keys created since May 28, 2026 are authorization keys.
Why you cannot hide an API key in a Chrome extension
When someone installs your extension, Chrome downloads the same files you uploaded and unpacks them into the browser profile. The package is a signed ZIP archive. Anyone can fetch it for any public extension, unzip it, and search the JavaScript for strings that look like keys. Tools exist that do exactly this at scale.
The common workarounds all fail for the same reason:
- Environment variables.
process.env.OPENAI_API_KEYin your source becomes a string literal in the bundle. The.envfile stays on your machine; its value does not. - Splitting or encoding the key. Base64, character codes and string concatenation slow a reader down by minutes. They also break store rules: the Chrome Web Store's code readability requirement lists Base64 encoding and character encoding as examples of obfuscation, under the rejection ID Red Titanium.
- WebAssembly or a native helper. The key still has to be in memory and in the outgoing request. The Network panel of the extension's DevTools shows the
Authorizationheader in plain text. chrome.storage. Storage protects nothing from the person using the browser. Chrome's own storage docs recommendstorage.sessionfor sensitive user data, which keeps it in memory, but that only helps for data that arrives at runtime, not for a key baked into your package.
Every major AI provider says the same thing. Google's Gemini API key guide is blunt: "Do not hardcode API keys directly in web or mobile apps. Keys compiled in client-side code can be extracted by users. To secure client-side apps, run a backend proxy server to make the actual API calls."
What researchers found in real extensions
This is not a theoretical risk. The CRX-ray study, presented at ACM ASIA CCS in June 2026, scanned 163,924 extensions across Chrome, Firefox and Edge. It found 3,677 unique leaked API keys in 4,145 extensions, including 300 exposed AI platform keys in 309 extensions that together serve 1,045,339 users.
A year earlier, Symantec researchers reported popular extensions shipping hard-coded secrets, including a Google Analytics 4 API secret that anyone could use to flood the property with fake events. If you send analytics from your extension, our guide to Google Analytics 4 in a Manifest V3 extension shows a relay that keeps that secret off the client too.
The cost of a leaked AI key is direct: someone else's traffic on your bill until you notice, and then an outage while you rotate the key and ship an update through store review.
Three patterns that work
| Pattern | Who pays the AI bill | Your effort | Best for |
|---|---|---|---|
| Backend proxy | You | A small server with auth and quotas | Most AI features, free tiers, paid plans |
| Bring your own key (BYOK) | The user | Settings UI and careful storage | Developer tools, power users |
| Short-lived tokens | You | A token endpoint on your server | Realtime voice and streaming APIs that support them |
Short-lived tokens are worth knowing about, but they only exist for specific APIs. OpenAI's Realtime API lets your server mint an ephemeral key for a browser WebRTC session, and Google's ephemeral tokens (in preview) work only with the Gemini Live API. Google notes that ephemeral tokens "can be extracted from client-side applications" too; they are safer because they expire quickly. For ordinary text generation, use a proxy.
Bring your own key shifts cost and risk to the user, which is fine for tools aimed at developers. Store the key in chrome.storage.local (never storage.sync, which copies it to every signed-in browser), send it only to the provider, never to your own server, and say so in your privacy policy. Users are right to be wary here: in January 2025, Obsidian Security found an extension that asked for an OpenAI key and quietly sent it to the attacker.
Build the proxy: architecture
The extension never talks to the AI provider. It talks to your API, which knows who the user is, decides whether they may make the call, and adds the key on the server:
- The user signs in to your service from the extension. Your backend issues a session token, which is revocable and identifies one user.
- The service worker calls your endpoint, for example
POST /v1/summarize, with that token. - The server checks the token, increments the user's daily counter, and refuses with
429when they are over their limit. - The server builds the provider request itself: fixed model, fixed system prompt, capped input and output size. Then it calls the provider with the real key.
- It records token usage per user and returns only the result.
Step 4 matters most. A proxy that forwards whatever prompt, model and max_tokens the client sends is an open AI API with your name on the bill. Chrome's security guidance makes the same point for content scripts: do not let them "trigger requests to arbitrary URLs or pass arbitrary arguments". Expose tasks, not a pipe.
Build the proxy: the server
This server uses Node.js 22 or later and the MongoDB driver, and calls the Claude Messages API. The same shape works for OpenAI or Gemini; only the upstream request changes.
// proxy.mjs: node --env-file=.env proxy.mjs
import { createServer } from 'node:http';
import { createHash } from 'node:crypto';
import { MongoClient } from 'mongodb';
const { ANTHROPIC_API_KEY, MODEL, MONGODB_URI } = process.env;
const DAILY_LIMIT = 50; // requests per user per UTC day
const MAX_INPUT_CHARS = 8_000;
const db = new MongoClient(MONGODB_URI).db('app');
const sessions = db.collection('sessions'); // { tokenHash, userId, expiresAt }
const usage = db.collection('ai_usage'); // one document per user per day
await usage.createIndex({ userId: 1, day: 1 }, { unique: true });
async function userIdFrom(req) {
const token = req.headers.authorization?.match(/^Bearer (.+)$/)?.[1];
if (!token) return null;
const tokenHash = createHash('sha256').update(token).digest('hex');
const session = await sessions.findOne({ tokenHash, expiresAt: { $gt: new Date() } });
return session?.userId ?? null;
}
async function readJson(req, maxBytes = 64_000) {
let raw = '';
for await (const chunk of req) {
raw += chunk;
if (raw.length > maxBytes) throw new Error('too_large');
}
return JSON.parse(raw);
}
async function summarize(text) {
const res = await fetch('https://api.anthropic.com/v1/messages', {
method: 'POST',
headers: {
authorization: `Bearer ${ANTHROPIC_API_KEY}`, // x-api-key is still accepted
'anthropic-version': '2023-06-01',
'content-type': 'application/json',
},
body: JSON.stringify({
model: MODEL, // chosen on the server, never by the client
max_tokens: 400,
system: 'Summarize the user text in three short bullet points.',
messages: [{ role: 'user', content: text }],
}),
});
if (!res.ok) throw new Error(`upstream_${res.status}`);
const data = await res.json();
return {
summary: data.content.filter((b) => b.type === 'text').map((b) => b.text).join(''),
tokens: { input: data.usage.input_tokens, output: data.usage.output_tokens },
};
}
createServer(async (req, res) => {
const send = (status, body) => {
res.writeHead(status, { 'content-type': 'application/json' });
res.end(JSON.stringify(body));
};
try {
if (req.method !== 'POST' || req.url !== '/v1/summarize') return send(404, { error: 'not_found' });
const userId = await userIdFrom(req);
if (!userId) return send(401, { error: 'sign_in_required' });
const { text } = await readJson(req);
if (typeof text !== 'string' || !text.trim()) return send(400, { error: 'text_required' });
const day = new Date().toISOString().slice(0, 10);
const counter = await usage.findOneAndUpdate(
{ userId, day },
{ $inc: { requests: 1 } },
{ upsert: true, returnDocument: 'after' },
);
if (counter.requests > DAILY_LIMIT) return send(429, { error: 'daily_limit' });
const result = await summarize(text.slice(0, MAX_INPUT_CHARS));
await usage.updateOne(
{ userId, day },
{ $inc: { inputTokens: result.tokens.input, outputTokens: result.tokens.output } },
);
return send(200, { summary: result.summary });
} catch (error) {
console.error(error);
return send(error.message === 'too_large' ? 413 : 502, { error: 'failed' });
}
}).listen(8787);
A few choices in that code are deliberate:
- Session tokens are stored hashed. A database leak then does not hand out working sessions, and revoking a user is a single delete.
- The counter is incremented before the provider call. Failed and slow requests still count, so a client that retries in a loop hits the limit instead of your budget.
- The unique index on
{ userId, day }makes the upsert safe under concurrent requests, and the same documents become your cost report: tokens per user per day, ready to compare with what each plan pays. - Errors are generic. The client never sees the upstream status body, which can include details about your account.
Put it behind HTTPS, add a per-IP rate limit at your proxy or CDN for unauthenticated traffic, and set a spend alert with the provider. If you store more history than a daily counter, such as per-request latency or token counts over months, our post on MongoDB time series collections for extension stats covers a schema built for that.
Call the proxy from the extension
Make the call from the service worker, not from a content script. Content scripts run inside web pages, and Chrome's guidance is to assume any secret sent to them can leak to the page. Add your API origin to host_permissions so the service worker can call it without CORS headers:
{
"manifest_version": 3,
"permissions": ["storage"],
"host_permissions": ["https://api.example.com/*"]
}
// background.js
async function summarize(text) {
const { sessionToken } = await chrome.storage.local.get('sessionToken');
if (!sessionToken) return { error: 'sign_in_required' };
const res = await fetch('https://api.example.com/v1/summarize', {
method: 'POST',
headers: { authorization: `Bearer ${sessionToken}`, 'content-type': 'application/json' },
body: JSON.stringify({ text }),
});
return res.json();
}
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (sender.id !== chrome.runtime.id || message?.type !== 'summarize') return;
summarize(String(message.text ?? '')).then(sendResponse);
return true; // keep the channel open for the async response
});
The session token is still on the client, and that is fine. It is tied to one account, it expires, you can revoke it, and it can only do what your endpoint allows within that user's quota. That is the whole difference between a session token and a provider key.
For a free tier without accounts, you can issue an anonymous token per install from your backend, with a much lower limit. Expect some people to mint many installs; per-IP limits and a sign-in requirement above a small free allowance keep that cheap.
Gemini keys changed in 2026
If you shipped a Gemini key in an extension at some point, check it now. Google's key documentation says that starting May 28, 2026, all new keys created in Google AI Studio are authorization keys bound to a service account, and that the Gemini API rejects requests from unrestricted standard keys. Standard keys with explicit restrictions keep working.
Two practical consequences: an old unrestricted key embedded in a client stops working, which may be how you find out it was there; and adding restrictions is not a substitute for a proxy, because the application restrictions available for a Google API key cover websites, IP addresses and Android or iOS apps, not a Chrome extension ID. Website restrictions rely on headers that any script outside a browser can set.
When a key leaks anyway
Have the response written down before you need it. Google's checklist for a leaked Gemini key applies to every provider:
- Create a replacement key and deploy it to your server.
- Disable the leaked key once the new one works.
- Audit usage and billing logs for unfamiliar traffic.
- If the key was in the extension itself, ship an update that removes it, and assume every installed copy still contains it until users update.
With a proxy, steps 1 and 2 take minutes and need no store review, which is one more reason to keep keys server-side.
Watch what users do with the AI feature
Shipping an AI feature changes how people rate an extension: slow answers, daily limits and wrong summaries show up in reviews within days. Track it on both sides. Your proxy's usage table shows how many people hit the limit; the store shows what they think of it. Extenify's extension review monitoring records new reviews, rating changes and weekly users for your extension and your competitors on the Chrome Web Store, Edge Add-ons and Firefox Add-ons, and alerts you the day something moves. Tracking one extension is free; the pricing page covers more. Our guide to reading review and rating trends explains how to tell a real problem from noise, and what to watch in the week after an extension release is a good checklist for the launch of the feature itself.
Frequently asked questions
Can I hide an API key in a Chrome extension with obfuscation?
No. The key must appear in the outgoing request, where DevTools shows it in plain text, and the Chrome Web Store rejects obfuscated code. Treat anything in the package as public.
Is chrome.storage secure enough for an API key?
It is fine for a key the user enters themselves (bring your own key), stored in storage.local and never synced. It does not help for your own key, because you would have to ship that key to put it there.
Can I restrict my OpenAI or Claude key to my extension ID?
No provider key can be bound to an extension ID, and headers such as Origin are easy to forge outside a browser. Put the key on a server and authenticate your users instead.
Do I need a server for an AI feature in an extension?
For your own key, yes, though it can be small: a serverless function or a single Node.js process is enough. The alternatives are letting users bring their own key, or running a model on the device.
What about analytics keys like a PostHog project token?
Some keys are designed to be public. A PostHog project token can only send events, as covered in our PostHog Chrome extension guide. Check each provider's docs: if a key can read data or spend money, it does not belong in the package.
Summary
You cannot hide an API key in a Chrome extension, so do not try. Keep provider keys on a server, give the extension a revocable session token, and expose fixed tasks with server-chosen models, capped sizes, per-user quotas and token metering. Use bring-your-own-key for developer tools and short-lived tokens only where the provider offers them. Then watch both your usage table and your store reviews, because that is where a costly or disappointing AI feature shows up first.
Image credits
- Cover photo: Old keys hanging on a chest by Silas Köhler on the WordPress Photo Directory, CC0.


