GuidesGITHUB PAGES

Fix CORS errors on GitHub Pages

GitHub Pages serves static files only, so there is no server to make API calls for you: a fetch() from your yourname.github.io page to an API that sends no Access-Control-Allow-Origin header fails with a CORS error in the browser. For public GET requests, put https://proxy.cors.dev/ in front of the API URL: free, no signup, no API key, JSON, XML and HTML responses up to 1 MiB within 10 seconds. The two other options that work are an API that already sends CORS headers, and a GitHub Actions workflow that fetches the data into your repository on a schedule.

Why the error happens on GitHub Pages

Your page runs on the origin https://yourname.github.io (or https://yourname.github.io/project for a project site, which shares the same origin, or your custom domain). When its JavaScript calls https://api.example.com, the browser sends the request and then refuses to hand the response to your code unless it contains Access-Control-Allow-Origin with your origin or *. Chrome reports Access to fetch at ... has been blocked by CORS policy: No Access-Control-Allow-Origin header is present on the requested resource and Firefox reports Cross-Origin Request Blocked. The request usually did reach the API; the browser hid the answer.

On a platform with server functions you would call the API from a function on your own origin. GitHub Pages has none: no rewrites, no proxies, no environment variables at runtime. Jekyll runs at build time and produces plain files, so the same applies to Jekyll sites. One check tells you which path you need.

Check whether the API allows your github.io origin
# No output: the API sends no CORS header, so the browser blocks the call
curl -sI -H 'Origin: https://yourname.github.io' \
  'https://export.arxiv.org/api/query?search_query=au:lastname&max_results=5' \
  | grep -i access-control-allow-origin

# access-control-allow-origin: * means fetch() works directly, no proxy needed
curl -sI -H 'Origin: https://yourname.github.io' https://api.github.com/users/octocat \
  | grep -i access-control-allow-origin

APIs you can call directly

Many APIs made for browsers send Access-Control-Allow-Origin: * and need no help: api.github.com, raw.githubusercontent.com, api.open-meteo.com, nominatim.openstreetmap.org and api.frankfurter.app all do. Call those directly. Putting them behind any proxy only shares one set of IP addresses between all your visitors, which is exactly how GitHub API rate limit exceeded hits a Pages site that proxies api.github.com.

Prefix a proxy for public APIs without CORS headers

For an API that sends no header, the fix on a static site is a proxy that makes the request server-side and re-serves the response with Access-Control-Allow-Origin set to your origin. With cors.dev the change is the URL: https://proxy.cors.dev/ followed by the full API URL. The example below lists one author's papers from the arXiv API, which returns Atom XML and sends no CORS header, a common need on academic Pages sites.

List your arXiv papers on a GitHub Pages site
// Markup: <ul id="papers"></ul>
const query = 'https://export.arxiv.org/api/query?search_query=au:lastname&max_results=20';

async function loadPapers() {
  const response = await fetch('https://proxy.cors.dev/' + query, { credentials: 'omit' });
  if (!response.ok) {
    throw new Error(`arXiv request failed: ${response.headers.get('X-Cors-Error') ?? response.status}`);
  }

  const feed = new DOMParser().parseFromString(await response.text(), 'application/xml');
  if (feed.querySelector('parsererror')) throw new Error('The response is not valid XML');

  return [...feed.getElementsByTagName('entry')].map((entry) => ({
    title: entry.getElementsByTagName('title')[0].textContent.trim(),
    url: entry.getElementsByTagName('id')[0].textContent.trim(),
    published: entry.getElementsByTagName('published')[0].textContent.slice(0, 10),
  }));
}

const list = document.querySelector('#papers');
loadPapers()
  .then((papers) => {
    list.replaceChildren(
      ...papers.map((paper) => {
        const item = document.createElement('li');
        const link = document.createElement('a');
        link.href = paper.url;
        link.textContent = paper.title;
        item.append(link, ` (${paper.published})`);
        return item;
      }),
    );
  })
  .catch((error) => {
    list.textContent = `Could not load papers: ${error.message}`;
  });

Anonymous requests work from github.io origins, custom domains and file: pages alike, so the same code runs when you open index.html locally. Responses are relayed with the upstream status and Content-Type; a proxy-side failure sets X-Cors-Error, for example response_too_large above 1 MiB or upstream_timeout after 10 seconds.

Fetch at build time with GitHub Actions

When the data changes rarely, or the API needs a key you must not publish, fetch it in a workflow instead. Actions runs on a server, so CORS does not apply, secrets stay in repository settings, and the page loads a static file from your own origin with no runtime dependency at all. The cost is freshness: data is as old as the last run.

.github/workflows/refresh-data.yml: fetch at build time instead
name: Refresh data
on:
  schedule:
    - cron: '0 6 * * *' # once a day, 06:00 UTC
  workflow_dispatch:
permissions:
  contents: write
jobs:
  refresh:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: |
          mkdir -p data
          curl -fsSL 'https://export.arxiv.org/api/query?search_query=au:lastname&max_results=20' \
            -o data/papers.xml
      - run: |
          git config user.name github-actions
          git config user.email github-actions@github.com
          git add data/papers.xml
          git diff --cached --quiet && exit 0
          git commit -m "Refresh papers" && git push

Your page then fetches /data/papers.xml same-origin. Combine both approaches when it helps: a workflow for keyed or slow APIs, the proxy prefix for live public data such as feeds, scores or prices.

Limits and pricing

cors.dev limits for a GitHub Pages site
LimitFree, no keyPro, $5 per month
Price$0$5 per month; 7-day trial with 1,000 requests, no card
MethodsGET and HEADGET, HEAD, POST, PUT, PATCH and DELETE
DestinationsAny public HTTPS hostPublic HTTPS hosts enabled on your Connection
Response size1 MiB, text types only6 MiB, any content type
Request bodyNone1 MiB
Upstream deadline10 seconds10 seconds
RateShared pool with fair-use limits600 requests per minute, 10 concurrent per account
Monthly requestsNo quota500,000 per billing period, $5 per extra 500,000
Request headers forwardedAccept, If-None-Match, If-Modified-SincePlus Authorization and custom headers
Page originsAny, including file: pagesOrigins listed on your Connection
CachingNone, no-storeOpt-in X-Cors-Cache, 1 to 300 seconds

Good questions.

Can GitHub Pages run server-side code?

No. Pages serves the files in your branch or build output as-is. The only server-side step you get is GitHub Actions at build time, which is why a scheduled workflow that writes a data file is the Pages equivalent of a backend fetch.

Does a custom domain change anything?

Your origin becomes https://www.yourdomain.com instead of https://yourname.github.io, so an API that allowed your github.io origin by name would stop working. APIs that send a wildcard, and the anonymous proxy, do not care which origin calls them.

Can I hide an API key on GitHub Pages?

Not in page code: everything the browser downloads is public, and that includes keys in JavaScript, in a config file, or in a proxied URL. Keep keyed calls in a GitHub Actions workflow using repository secrets, or on a serverless function elsewhere.

Why does fetching a raw GitHub file work without a proxy?

raw.githubusercontent.com sends Access-Control-Allow-Origin: *, so the browser allows it. Many content hosts do the same; the curl check above shows you in one line whether a URL needs the prefix at all.

What happens when the API is slow or the response is big?

The free tier gives the upstream 10 seconds and relays text responses up to 1 MiB; above that you get 502 with X-Cors-Error: response_too_large. Pro raises the size to 6 MiB and any content type. Status codes from the API itself, such as 404 or 500, arrive unchanged.

Does this work with Netlify, Vercel or Cloudflare Pages too?

The browser side is identical. Those hosts also offer serverless functions and rewrites, so you can put the API call on your own origin instead; GitHub Pages is the one of the four with no such option.