Chrome platform apps have always been a weird corner of the web platform.
They look like web apps, use web APIs, and make HTTP requests like a browser. But they also run with elevated privileges and their network behavior does not match a normal tab. If you work on CORS-heavy APIs, that difference matters.
This guide is the practical version: what changes, what still applies, and what headers you actually need.
First: what “Chrome platform apps” means
Historically this usually meant Chrome Apps and extension-like packaged apps running on the Chrome platform. A lot of developers also mix in Chrome extensions because the network model is similar in the places that matter for CORS.
The key idea is simple:
- a normal web page is bound by the browser’s same-origin policy
- a Chrome platform app can often request cross-origin resources if its manifest grants permission
- that does not mean CORS disappears everywhere
- it means the security model shifts from “page origin only” to “browser runtime + declared permissions”
If you only remember one thing, remember this:
Cross-origin access in Chrome apps/extensions is mostly permission-driven, not purely header-driven.
Normal web page CORS vs Chrome app CORS
For a regular website, fetch() to another origin succeeds only if the server allows it with CORS headers.
Example from a normal website:
const res = await fetch("https://api.github.com/users/octocat");
const data = await res.json();
console.log(data);
That works in the browser because 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, Warning
That’s a real-world example of permissive public API CORS.
A Chrome platform app may be able to call the same endpoint even when the server does not send those CORS headers, assuming the app has the right host permissions. That’s the part that surprises backend teams.
The manifest decides a lot
For Chrome platform apps and extensions, cross-origin access is typically declared up front.
A host permission example:
{
"name": "CORS Demo App",
"version": "1.0.0",
"manifest_version": 3,
"permissions": [],
"host_permissions": [
"https://api.github.com/*",
"https://example.com/*"
]
}
If you’re dealing with older Chrome app formats, you’ll see permissions expressed differently, but the idea is the same: the runtime checks what origins your app is allowed to talk to.
Without host permission, this fails at the platform level even before you get into server CORS policy.
What requests look like in practice
1. Simple cross-origin GET
const res = await fetch("https://api.github.com/rate_limit");
console.log(res.status);
console.log(res.headers.get("x-ratelimit-remaining"));
console.log(await res.text());
For a normal page, reading x-ratelimit-remaining depends on Access-Control-Expose-Headers.
GitHub explicitly exposes it:
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, Warning
That is why browser JavaScript can read it.
In a Chrome app with host permission, the request is less constrained by page-origin CORS rules, but you should still assume API behavior and header visibility matter when you want code to work both in apps and in regular browsers.
2. POST with JSON
This is where people hit preflights in regular web pages.
const res = await fetch("https://api.example.com/widgets", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": "Bearer TOKEN"
},
body: JSON.stringify({ name: "demo" })
});
console.log(res.status);
console.log(await res.text());
On a normal site, that usually triggers an OPTIONS preflight because:
- method is not a simple GET/HEAD/POST case anymore in practice due to headers
Content-Type: application/jsonis not a simple content typeAuthorizationis a non-simple request header
The server then needs something like:
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: POST, OPTIONS
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Max-Age: 600
In a Chrome platform app, host permissions can allow the request even if the endpoint was never built for browser CORS. That’s convenient, but dangerous if your backend team assumes “browser clients can’t call this directly.”
I’ve seen internal APIs exposed by accident because someone relied on missing CORS headers as access control. That is not access control. It never was.
CORS headers still matter for shared code
If your request code runs in both:
- a website
- a Chrome app or extension
then you still need proper CORS responses from the server or your website version will fail.
A safe API response for public GET access:
Access-Control-Allow-Origin: *
Access-Control-Expose-Headers: ETag, Link, Location, Retry-After, X-RateLimit-Limit, X-RateLimit-Remaining
Vary: Origin
A credentialed API response:
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: true
Access-Control-Expose-Headers: ETag, X-Request-Id
Vary: Origin
Do not combine:
Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials: true
Browsers reject that combination.
Credentials and cookies
Chrome platform apps often use token-based auth instead of ambient cookies. Good. Keep it that way.
If you rely on cookies, the behavior gets messy fast:
const res = await fetch("https://api.example.com/me", {
credentials: "include"
});
For a regular web page, the server must allow credentials:
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: true
For Chrome apps/extensions, cookie behavior can differ depending on context and permissions, and that usually becomes a maintenance headache. My advice: prefer explicit bearer tokens over cross-site cookies unless you have a strong reason not to.
Reading response headers
This is one of the easiest places to get tripped up.
From a normal browser page, JavaScript can only read:
- CORS safelisted response headers
- headers named in
Access-Control-Expose-Headers
GitHub does this correctly. Real header example again:
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, Warning
Copy-paste test:
const res = await fetch("https://api.github.com/users/octocat");
console.log("etag", res.headers.get("etag"));
console.log("link", res.headers.get("link"));
console.log("x-ratelimit-limit", res.headers.get("x-ratelimit-limit"));
If your API returns useful metadata in custom headers, expose them. Otherwise frontend code can’t see them in standard web contexts.
A backend CORS template that works well
For an API that serves both browser pages and Chrome platform apps, I’d use something like this on sensitive endpoints:
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETE, OPTIONS
Access-Control-Allow-Headers: Authorization, Content-Type, If-None-Match
Access-Control-Expose-Headers: ETag, Link, Location, Retry-After, X-Request-Id
Access-Control-Max-Age: 600
Vary: Origin
And for public read-only endpoints:
Access-Control-Allow-Origin: *
Access-Control-Expose-Headers: ETag, Link, Retry-After, X-RateLimit-Limit, X-RateLimit-Remaining
That split keeps public resources easy to consume and private resources explicit.
Common mistakes
Treating lack of CORS as protection
Bad assumption:
“The browser blocks it, so our API is safe.”
A Chrome platform app with permissions, a server-side client, curl, or mobile app does not care about your missing Access-Control-Allow-Origin.
Use real auth and authorization.
Forgetting preflight support
If your frontend sends JSON or Authorization, your server probably needs to answer OPTIONS.
Minimal example:
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST, OPTIONS
Access-Control-Allow-Headers: Authorization, Content-Type
Access-Control-Max-Age: 600
Vary: Origin
Not exposing headers you expect frontend code to read
If the UI needs ETag, pagination Link, or rate-limit headers, expose them.
Wildcards on private APIs
Access-Control-Allow-Origin: * is fine for public, unauthenticated resources.
It’s a bad default for anything user-specific.
Debugging checklist
When a request fails, check these in order:
- Does the Chrome app/extension manifest allow the target origin?
- Is the request coming from a normal page context or privileged app context?
- Is there a preflight?
- Does the server handle
OPTIONS? - Does
Access-Control-Allow-Originmatch the caller? - Are required request headers allowed?
- Are needed response headers exposed?
That order saves time.
Security angle
CORS is not a general security boundary. It’s a browser read-sharing policy.
For Chrome platform apps, the browser may trust the app because the user installed it and the manifest granted access. Your server should not confuse that with user authorization.
If you’re reviewing the broader header posture around APIs, CSP and related headers are a separate topic. If you need that, the only non-official reference I’d point at is https://csp-guide.com. For Chrome platform app behavior itself, stick to Chrome’s official docs.
Copy-paste examples
Frontend fetch against GitHub
const res = await fetch("https://api.github.com/repos/octocat/Hello-World");
const json = await res.json();
console.log("status:", res.status);
console.log("etag:", res.headers.get("etag"));
console.log("rate remaining:", res.headers.get("x-ratelimit-remaining"));
console.log("repo:", json.full_name);
Manifest host permission
{
"name": "API Client",
"version": "1.0.0",
"manifest_version": 3,
"host_permissions": [
"https://api.github.com/*",
"https://api.example.com/*"
]
}
Express preflight handler
app.options("/widgets", (req, res) => {
res.set({
"Access-Control-Allow-Origin": "https://app.example.com",
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
"Access-Control-Allow-Headers": "Authorization, Content-Type",
"Access-Control-Max-Age": "600",
"Vary": "Origin"
});
res.status(204).end();
});
Express API response with exposed headers
app.get("/widgets", (req, res) => {
res.set({
"Access-Control-Allow-Origin": "https://app.example.com",
"Access-Control-Expose-Headers": "ETag, Link, X-Request-Id",
"ETag": "\"abc123\"",
"X-Request-Id": "req_123"
});
res.json([{ id: 1, name: "demo" }]);
});
If you build for both websites and Chrome platform apps, the boring answer is the right one: keep your API CORS-correct anyway, declare host permissions narrowly, and never treat CORS as auth. That combination holds up better than clever shortcuts.