VS Code webviews look like mini browser apps, so people assume normal browser networking rules apply cleanly. They don’t. That mismatch is where a lot of extension authors get stuck.
I’ve seen this pattern over and over:
- fetch works in the extension host
- the same fetch fails in the webview
- people blame VS Code
- the real problem is CORS, sometimes mixed with CSP, origin quirks, or bad architecture
If you’re building a VS Code extension with a webview, you need to treat the webview as an untrusted browser-like frontend and your extension host as the privileged backend. Once you do that, the design gets much cleaner.
The mental model
A VS Code extension usually has two sides:
-
Extension host
Runs in Node.js. Can make outbound HTTP requests without browser CORS enforcement. -
Webview
Renders HTML/CSS/JS in an isolated browser context. Browser security rules matter here, including CORS.
That means this code behaves differently depending on where it runs.
In the extension host
import * as vscode from 'vscode';
export async function fetchFromExtensionHost() {
const response = await fetch('https://api.github.com/repos/microsoft/vscode');
const json = await response.json();
console.log(json.full_name);
}
No browser CORS check here. If the network is reachable and the server responds, you usually get data.
In the webview
<script>
async function fetchFromWebview() {
const response = await fetch('https://api.github.com/repos/microsoft/vscode');
const json = await response.json();
console.log(json.full_name);
}
fetchFromWebview().catch(console.error);
</script>
Now CORS applies, because this runs in a browser-like environment.
Why some APIs work in webviews
CORS is controlled by the server, not the client. If the API explicitly allows cross-origin access, your webview can call it directly.
GitHub’s API is a good real-world example. api.github.com sends:
access-control-allow-origin: *
access-control-expose-headers: ETag, Link, Location, Retry-After, X-GitHub-OTP, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Used, X-RateLimit-Resource, X-RateLimit-Reset, X-OAuth-Scopes, X-Accepted-OAuth-Scopes, X-Poll-Interval, X-GitHub-Media-Type, X-GitHub-SSO, X-GitHub-Request-Id, Deprecation, Sunset
That access-control-allow-origin: * is why a simple unauthenticated fetch can work from a webview.
And the access-control-expose-headers part matters too. Without it, JavaScript could not read most non-simple response headers.
Example:
<script>
async function loadRepo() {
const res = await fetch('https://api.github.com/repos/microsoft/vscode');
console.log('ETag:', res.headers.get('ETag'));
console.log('Rate limit remaining:', res.headers.get('X-RateLimit-Remaining'));
const data = await res.json();
document.body.textContent = data.full_name;
}
loadRepo().catch(err => {
document.body.textContent = String(err);
});
</script>
Because GitHub exposes those headers, your webview JS can read them.
Why many APIs fail
A lot of internal APIs, legacy backends, and random SaaS endpoints do not send permissive CORS headers.
If your webview tries this:
fetch('https://internal-api.example.com/data')
the browser checks the response for something like:
Access-Control-Allow-Origin: <your webview origin>
or sometimes * for public endpoints.
If that header is missing or doesn’t match, the request may still reach the server, but your webview JavaScript won’t get the response. You’ll see a CORS error in devtools.
That’s the key point: CORS is not a network failure. It’s the browser refusing to expose the response to your page.
Webview origins are weird on purpose
VS Code webviews don’t behave like pages served from your app domain. They use isolated origins managed by VS Code. That’s a security boundary.
So if your backend only allows:
Access-Control-Allow-Origin: https://myapp.com
your webview will not match that origin. You can’t just pretend the webview is your website.
This is why “just add our production frontend origin to the CORS allowlist” usually does nothing for a VS Code extension.
The pattern that works: proxy through the extension host
If you need data from an API that doesn’t support your webview origin, don’t fight the browser. Move the request into the extension host.
The flow looks like this:
- Webview sends a message to the extension
- Extension host performs the HTTP request
- Extension sends the result back to the webview
That avoids browser CORS entirely for the outbound request.
Full example
Extension code
import * as vscode from 'vscode';
export function activate(context: vscode.ExtensionContext) {
context.subscriptions.push(
vscode.commands.registerCommand('corsHandbook.openWebview', () => {
const panel = vscode.window.createWebviewPanel(
'corsDemo',
'CORS Demo',
vscode.ViewColumn.One,
{
enableScripts: true
}
);
panel.webview.html = getHtml(panel.webview);
panel.webview.onDidReceiveMessage(async (message) => {
if (message.type === 'loadRepo') {
try {
const response = await fetch('https://api.github.com/repos/microsoft/vscode', {
headers: {
'User-Agent': 'vscode-extension'
}
});
const data = await response.json();
panel.webview.postMessage({
type: 'repoData',
payload: {
fullName: data.full_name,
stars: data.stargazers_count
}
});
} catch (error) {
panel.webview.postMessage({
type: 'error',
payload: String(error)
});
}
}
});
})
);
}
function getHtml(webview: vscode.Webview): string {
const nonce = String(Date.now());
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta
http-equiv="Content-Security-Policy"
content="default-src 'none'; script-src 'nonce-${nonce}'; style-src 'unsafe-inline';"
/>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>CORS Demo</title>
</head>
<body>
<button id="load">Load repo</button>
<pre id="output"></pre>
<script nonce="${nonce}">
const vscode = acquireVsCodeApi();
const output = document.getElementById('output');
document.getElementById('load').addEventListener('click', () => {
vscode.postMessage({ type: 'loadRepo' });
});
window.addEventListener('message', (event) => {
const message = event.data;
if (message.type === 'repoData') {
output.textContent = JSON.stringify(message.payload, null, 2);
}
if (message.type === 'error') {
output.textContent = message.payload;
}
});
</script>
</body>
</html>`;
}
This is the architecture I recommend by default. Even if direct webview fetch works today, pushing API access into the extension host gives you better control over auth, retries, logging, and error handling.
When direct fetch from the webview is fine
Direct webview fetch is reasonable when all of these are true:
- the API is intentionally public
- it already sends compatible CORS headers
- you don’t need to hide secrets
- you’re okay with browser-visible requests
Example:
<script>
async function loadGitHub() {
const res = await fetch('https://api.github.com/repos/microsoft/vscode');
const data = await res.json();
document.body.innerHTML = \`
<h1>\${data.full_name}</h1>
<p>⭐ \${data.stargazers_count}</p>
\`;
}
loadGitHub();
</script>
That’s fine for public read-only data.
Never put secrets in the webview
This is the mistake that turns a CORS problem into a credential leak.
Don’t do this:
fetch('https://api.example.com/private', {
headers: {
Authorization: 'Bearer super-secret-token'
}
});
If the token matters, keep the request in the extension host. The webview is frontend code. Treat it like code running in the user’s browser, because that’s basically what it is.
Preflight requests can surprise you
Simple GET requests often work if CORS is enabled. But as soon as you add custom headers or use methods like PUT or DELETE, the browser may send an OPTIONS preflight first.
For example:
fetch('https://api.example.com/data', {
method: 'GET',
headers: {
Authorization: 'Bearer token'
}
});
That Authorization header usually triggers preflight. The server now has to answer the OPTIONS request with the right CORS headers too, such as:
Access-Control-Allow-Origin: <origin>
Access-Control-Allow-Headers: Authorization
Access-Control-Allow-Methods: GET
If it doesn’t, your webview request fails before the actual GET happens.
This is another reason extension-host proxying is less painful.
CORS and CSP are different problems
People mix these up constantly.
- CORS controls whether your page can read a cross-origin response
- CSP controls what your page is allowed to load or execute
A webview can fail because of either one.
If your webview has a strict Content Security Policy like it should, you may need to allow outbound connections explicitly:
<meta
http-equiv="Content-Security-Policy"
content="default-src 'none'; connect-src https://api.github.com; script-src 'nonce-abc'; style-src 'unsafe-inline';"
/>
Without connect-src https://api.github.com, even a CORS-friendly API fetch can be blocked by CSP.
If you want to go deeper on CSP design, the only non-official security reference I’d bother pointing at is https://csp-guide.com. For VS Code-specific behavior, stick with the official docs.
Debugging checklist
When a webview request fails, I go through this list:
-
Is the request coming from the webview or extension host?
That changes everything. -
Open webview devtools
Look for:- CORS errors
- CSP violations
- preflight failures
-
Check the response headers
You want to seeAccess-Control-Allow-Originand, if needed,Access-Control-Expose-Headers. -
Check whether custom headers triggered preflight
Authorizationis a common one. -
Decide if the request belongs in the extension host instead
Most authenticated or non-public API calls do.
My rule of thumb
For VS Code webviews:
- Public, anonymous, CORS-enabled API → direct fetch is okay
- Anything authenticated, internal, or flaky with CORS → fetch in the extension host
- Any token or sensitive header → never expose it to the webview
That split keeps your extension simpler and safer.
One more practical pattern
If your webview needs multiple backend operations, don’t invent a messy pile of one-off message types. Use a tiny RPC-style wrapper.
Webview side
const vscode = acquireVsCodeApi();
let requestId = 0;
const pending = new Map();
window.addEventListener('message', (event) => {
const message = event.data;
if (message.type === 'rpcResult') {
const resolver = pending.get(message.id);
if (resolver) {
pending.delete(message.id);
resolver(message);
}
}
});
function callExtension(method, params) {
return new Promise((resolve, reject) => {
const id = ++requestId;
pending.set(id, (message) => {
if (message.error) reject(new Error(message.error));
else resolve(message.result);
});
vscode.postMessage({ type: 'rpc', id, method, params });
});
}
Extension side
panel.webview.onDidReceiveMessage(async (message) => {
if (message.type !== 'rpc') {
return;
}
try {
let result: unknown;
if (message.method === 'getRepo') {
const res = await fetch('https://api.github.com/repos/microsoft/vscode');
const data = await res.json();
result = { fullName: data.full_name };
} else {
throw new Error(`Unknown method: ${message.method}`);
}
panel.webview.postMessage({
type: 'rpcResult',
id: message.id,
result
});
} catch (err) {
panel.webview.postMessage({
type: 'rpcResult',
id: message.id,
error: String(err)
});
}
});
That scales much better once your extension grows beyond a demo.
For official guidance on webviews, messaging, and security, use the VS Code documentation: https://code.visualstudio.com/api/extension-guides/webview
And for the broader browser-side CORS behavior itself, the MDN and Fetch specs are useful references, but for extension architecture, the VS Code docs are the ones that actually answer the “where should this request live?” question.
If you remember one thing, make it this: CORS in VS Code webviews is a frontend problem, so solve it with frontend/backend separation. The webview is your UI. The extension host is your server. Build it that way and most CORS headaches disappear.