[Bug]: Actual fails to sync when run under Cloudflare Zero Trust due to the expired auth token and no CORS #1879

Closed
opened 2026-02-28 19:57:04 -06:00 by GiteaMirror · 7 comments
Owner

Originally created by @gtrubach on GitHub (Feb 20, 2025).

Verified issue does not already exist?

  • I have searched and found no existing issue

What happened?

Hi,

First of all thanks for this great project!

I'm running Actual behind Cloudflare with ZeroTrust. CF issues an CF_Authorization token after login which expires after 1 day. When expired, Actual fails to call /sync endpoint as CF rejects the request due to the expired token.

Access to fetch at 'https://<cf-account-name>.cloudflareaccess.com/cdn-cgi/access/login/<fqdn>?kid=<redacted>&redirect_url=%2Fsync%2Fsync&meta=<redacted>' (redirected from 'https://<fqdn>/sync/sync') from origin 'https://<fqdn>' 
has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource. 
If an opaque response serves your needs, set the request's mode to 'no-cors' to fetch the resource with CORS disabled.

There was a similar change lately https://github.com/actualbudget/actual/pull/3286, where some similar issues were fixed but unfortunately it does not work with CF ZeroTrust. But calling this code from PR works!

window.navigator.serviceWorker
      .getRegistration('/')
      .then(registration => {
        if (registration == null) return;
        return registration.unregister();
      })
      .then(() => {
        window.location.reload();
      });

This leads me to the thought that something is wrong with the if condition in this line https://github.com/actualbudget/actual/blob/master/packages/loot-core/src/platform/server/fetch/index.web.ts#L13. Also similar reports can be found in the mentioned PR https://github.com/actualbudget/actual/pull/3286#issuecomment-2646377751.

It would be great if this is fixed as it makes the setup a bit unusable due to the need to clean all cookies manually daily.

How can we reproduce the issue?

  1. Setup Cloudflare zero trust application using any auth method
  2. Put Actual behind it
  3. Access Actual and login
  4. Delete CF_Authorization cookie (or wait 1 day to expire)
  5. Observe that server goes offline and sync calls fail.

Where are you hosting Actual?

Docker

What browsers are you seeing the problem on?

Chrome

Operating System

Windows 11

Originally created by @gtrubach on GitHub (Feb 20, 2025). ### Verified issue does not already exist? - [x] I have searched and found no existing issue ### What happened? Hi, First of all thanks for this great project! I'm running Actual behind Cloudflare with ZeroTrust. CF issues an CF_Authorization token after login which expires after 1 day. When expired, Actual fails to call /sync endpoint as CF rejects the request due to the expired token. ``` Access to fetch at 'https://<cf-account-name>.cloudflareaccess.com/cdn-cgi/access/login/<fqdn>?kid=<redacted>&redirect_url=%2Fsync%2Fsync&meta=<redacted>' (redirected from 'https://<fqdn>/sync/sync') from origin 'https://<fqdn>' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource. If an opaque response serves your needs, set the request's mode to 'no-cors' to fetch the resource with CORS disabled. ``` There was a similar change lately https://github.com/actualbudget/actual/pull/3286, where some similar issues were fixed but unfortunately it does not work with CF ZeroTrust. But calling this code from PR works! ``` window.navigator.serviceWorker .getRegistration('/') .then(registration => { if (registration == null) return; return registration.unregister(); }) .then(() => { window.location.reload(); }); ``` This leads me to the thought that something is wrong with the if condition in this line https://github.com/actualbudget/actual/blob/master/packages/loot-core/src/platform/server/fetch/index.web.ts#L13. Also similar reports can be found in the mentioned PR https://github.com/actualbudget/actual/pull/3286#issuecomment-2646377751. It would be great if this is fixed as it makes the setup a bit unusable due to the need to clean all cookies manually daily. ### How can we reproduce the issue? 1. Setup Cloudflare zero trust application using any auth method 2. Put Actual behind it 3. Access Actual and login 4. Delete CF_Authorization cookie (or wait 1 day to expire) 5. Observe that server goes offline and sync calls fail. ### Where are you hosting Actual? Docker ### What browsers are you seeing the problem on? Chrome ### Operating System Windows 11
GiteaMirror added the bug label 2026-02-28 19:57:04 -06:00
Author
Owner

@gtrubach commented on GitHub (Feb 20, 2025):

Also it seems it corrupted my pwa
I tried to switch servers back and forth, but now it says that the server is not running under provided URL. The only option is to delete all cookies which I cannot do as Edge on IPhone can only delete cookies for all web sites...

@gtrubach commented on GitHub (Feb 20, 2025): Also it seems it corrupted my pwa I tried to switch servers back and forth, but now it says that the server is not running under provided URL. The only option is to delete all cookies which I cannot do as Edge on IPhone can only delete cookies for all web sites...
Author
Owner

@mathisgauthey commented on GitHub (Feb 28, 2025):

Got the same issue on my end. Response body is not available to scripts (Reason: CORS Missing Allow Origin)

My setup is defined here and involves Cloudflare tunnel with access restriction and Nginx Proxy Manager.

@mathisgauthey commented on GitHub (Feb 28, 2025): Got the same issue on my end. `Response body is not available to scripts (Reason: CORS Missing Allow Origin)` My setup is defined [here](https://github.com/NginxProxyManager/nginx-proxy-manager/discussions/4379) and involves Cloudflare tunnel with access restriction and Nginx Proxy Manager.
Author
Owner

@KenGrinder commented on GitHub (Mar 5, 2025):

How to Fix CORS Issues with Cloudflare Access (Advanced Settings)

Below are the settings I’m using in Cloudflare. I asked ChatGPT to write a guide based on my settings to make it easier for anyone else that may have the same issue. I have my session duration set to 15 minutes and it's been working fine for the past ~24 hours.

Image

1. Open Your Application in Cloudflare Zero Trust

  1. Log into your Cloudflare Zero Trust dashboard.
  2. In the left-hand menu, navigate to Access → Applications.
  3. Select the Access-protected application you need to configure.

2. Go to Advanced SettingsCORS

  1. In the application view, click the Advanced settings tab near the top.
  2. Under Advanced settings, select Cross-Origin Resource Sharing (CORS) settings.

3. Configure the CORS Settings

Below are the recommended settings for most cases:

  • Bypass options requests to origin: Off
    This ensures Cloudflare injects CORS headers for OPTIONS (preflight) requests.

  • Access-Control-Allow-Credentials: On
    Required if your app uses cookies or authorization headers.

  • Access-Control-Max-Age (seconds): 86400
    Caches the preflight response for 24 hours.

  • Access-Control-Allow-Origin:
    Add your specific domain here, for example https://actual.XXXXXXXXX.com.
    Avoid using “Allow all origins” if possible for better security.

  • Access-Control-Allow-Methods: All methods
    Ensures the browser sees allowed methods like GET, POST, PUT, OPTIONS, etc.

  • Access-Control-Allow-Headers: All http headers
    Alternatively, list only the headers you need (e.g. Content-Type, Authorization).

@KenGrinder commented on GitHub (Mar 5, 2025): # How to Fix CORS Issues with Cloudflare Access (Advanced Settings) Below are the settings I’m using in Cloudflare. I asked ChatGPT to write a guide based on my settings to make it easier for anyone else that may have the same issue. I have my session duration set to 15 minutes and it's been working fine for the past ~24 hours. ![Image](https://github.com/user-attachments/assets/3324c987-c053-4a63-b7f6-02cbdf183b31) ## 1. Open Your Application in Cloudflare Zero Trust 1. **Log into** your [Cloudflare Zero Trust dashboard](https://dash.teams.cloudflare.com/). 2. In the left-hand menu, navigate to **Access → Applications**. 3. Select the **Access-protected application** you need to configure. ## 2. Go to *Advanced Settings* → *CORS* 1. In the application view, click the **Advanced settings** tab near the top. 2. Under **Advanced settings**, select **Cross-Origin Resource Sharing (CORS) settings**. ## 3. Configure the CORS Settings Below are the recommended settings for most cases: - **Bypass options requests to origin**: **Off** *This ensures Cloudflare injects CORS headers for OPTIONS (preflight) requests.* - **Access-Control-Allow-Credentials**: **On** *Required if your app uses cookies or authorization headers.* - **Access-Control-Max-Age (seconds)**: `86400` *Caches the preflight response for 24 hours.* - **Access-Control-Allow-Origin**: Add your specific domain here, for example `https://actual.XXXXXXXXX.com`. *Avoid using “Allow all origins” if possible for better security.* - **Access-Control-Allow-Methods**: **All methods** *Ensures the browser sees allowed methods like GET, POST, PUT, OPTIONS, etc.* - **Access-Control-Allow-Headers**: **All http headers** *Alternatively, list only the headers you need (e.g. `Content-Type, Authorization`).*
Author
Owner

@mathisgauthey commented on GitHub (Mar 6, 2025):

How to Fix CORS Issues with Cloudflare Access (Advanced Settings)

Below are the settings I’m using in Cloudflare. I asked ChatGPT to write a guide based on my settings to make it easier for anyone else that may have the same issue. I have my session duration set to 15 minutes and it's been working fine for the past ~24 hours.

Image

1. Open Your Application in Cloudflare Zero Trust

1. **Log into** your [Cloudflare Zero Trust dashboard](https://dash.teams.cloudflare.com/).

2. In the left-hand menu, navigate to **Access → Applications**.

3. Select the **Access-protected application** you need to configure.

2. Go to Advanced SettingsCORS

1. In the application view, click the **Advanced settings** tab near the top.

2. Under **Advanced settings**, select **Cross-Origin Resource Sharing (CORS) settings**.

3. Configure the CORS Settings

Below are the recommended settings for most cases:

* **Bypass options requests to origin**: **Off**
  _This ensures Cloudflare injects CORS headers for OPTIONS (preflight) requests._

* **Access-Control-Allow-Credentials**: **On**
  _Required if your app uses cookies or authorization headers._

* **Access-Control-Max-Age (seconds)**: `86400`
  _Caches the preflight response for 24 hours._

* **Access-Control-Allow-Origin**:
  Add your specific domain here, for example `https://actual.XXXXXXXXX.com`.
  _Avoid using “Allow all origins” if possible for better security._

* **Access-Control-Allow-Methods**: **All methods**
  _Ensures the browser sees allowed methods like GET, POST, PUT, OPTIONS, etc._

* **Access-Control-Allow-Headers**: **All http headers**
  _Alternatively, list only the headers you need (e.g. `Content-Type, Authorization`)._

That is the thing I have been trying for the last two days without yet facing any issues, I can only vouch for that workaround.

It doesn't work on my end. Wether I set it up using an application with domain.tld and/or *.domain.tld, or creating a specific application on cloudflare for actual budget, I still get the same issues. Response body is not available to scripts (Reason: CORS Missing Allow Origin)

@mathisgauthey commented on GitHub (Mar 6, 2025): > # How to Fix CORS Issues with Cloudflare Access (Advanced Settings) > > Below are the settings I’m using in Cloudflare. I asked ChatGPT to write a guide based on my settings to make it easier for anyone else that may have the same issue. I have my session duration set to 15 minutes and it's been working fine for the past ~24 hours. > > ![Image](https://github.com/user-attachments/assets/3324c987-c053-4a63-b7f6-02cbdf183b31) > ## 1. Open Your Application in Cloudflare Zero Trust > > 1. **Log into** your [Cloudflare Zero Trust dashboard](https://dash.teams.cloudflare.com/). > > 2. In the left-hand menu, navigate to **Access → Applications**. > > 3. Select the **Access-protected application** you need to configure. > > > ## 2. Go to _Advanced Settings_ → _CORS_ > > 1. In the application view, click the **Advanced settings** tab near the top. > > 2. Under **Advanced settings**, select **Cross-Origin Resource Sharing (CORS) settings**. > > > ## 3. Configure the CORS Settings > > Below are the recommended settings for most cases: > > * **Bypass options requests to origin**: **Off** > _This ensures Cloudflare injects CORS headers for OPTIONS (preflight) requests._ > > * **Access-Control-Allow-Credentials**: **On** > _Required if your app uses cookies or authorization headers._ > > * **Access-Control-Max-Age (seconds)**: `86400` > _Caches the preflight response for 24 hours._ > > * **Access-Control-Allow-Origin**: > Add your specific domain here, for example `https://actual.XXXXXXXXX.com`. > _Avoid using “Allow all origins” if possible for better security._ > > * **Access-Control-Allow-Methods**: **All methods** > _Ensures the browser sees allowed methods like GET, POST, PUT, OPTIONS, etc._ > > * **Access-Control-Allow-Headers**: **All http headers** > _Alternatively, list only the headers you need (e.g. `Content-Type, Authorization`)._ ~That is the thing I have been trying for the last two days without yet facing any issues, I can only vouch for that workaround.~ It doesn't work on my end. Wether I set it up using an application with `domain.tld` and/or `*.domain.tld`, or creating a specific application on cloudflare for actual budget, I still get the same issues. `Response body is not available to scripts (Reason: CORS Missing Allow Origin)`
Author
Owner

@KenGrinder commented on GitHub (Mar 9, 2025):

Ah, I can confirm shortly after my instance stopped working.

Full disclosure, I barely know TypeScript and this was mostly created using AI - So this code may be awful/redundant/or incorrect, This still requires the access control allow credentials and allow origin in CF-. I've only been able to test for a few hours in a Docker after deleting the CF_Auth tokens with success, will need some additional testing to confirm.

The issue is the worker for the app stays in cache and upon auth expiration it's not able to prompt for the access page while the service is registered in cache. (You can verify this but manually unregistering the service worker via inspect element) -

For this code change, Upon auth expiration, it unregisters the worker, and prompts to re-auth through Cloudflare access.
I definitely welcome any feedback from someone who actually knows what they are talking about.

Here is my proposed changed:

Updated Fetch Wrapper to Catch Authentication Redirects

File updated:
packages/loot-core/src/platform/server/fetch/index.web.ts

  • Enhancement: Modified the fetch wrapper to use redirect: 'manual' for requests and handle both redirect indicators and opaque redirect responses.

Replaced:

import * as connection from '../connection';

export const fetch = async (
  input: RequestInfo | URL,
  options?: RequestInit,
): Promise<Response> => {
  const response = await globalThis.fetch(input, options);

  // Detect if the API query has been redirected to a different origin. This may indicate that the
  // request has been intercepted by an authentication proxy
  const originalUrl = new URL(input instanceof Request ? input.url : input);
  const responseUrl = new URL(response.url);
  if (response.redirected && responseUrl.host !== originalUrl.host) {
    connection.send('api-fetch-redirected');
    throw new Error(`API request redirected to ${responseUrl.host}`);
  }

  return response;
};

With:

import * as connection from '../connection';

export const fetch = async (
  input: RequestInfo | URL,
  options?: RequestInit
): Promise<Response> => {
  // Ensure manual redirect mode to catch login redirects
  const opts: RequestInit = { ...(options || {}), redirect: 'manual' };
  const response = await globalThis.fetch(input, opts);

  // Original request URL and final response URL
  const originalUrl = new URL(input instanceof Request ? input.url : String(input));
  const responseUrl = new URL(response.url);

  // If redirect occurred to a different origin or an opaque redirect was returned, trigger re-auth flow
  const differentOrigin = responseUrl.host !== originalUrl.host;
  if ((response.redirected && differentOrigin) || response.type === 'opaqueredirect') {
    connection.send('api-fetch-redirected');  // notify the app to reload/authenticate
    throw new Error(`API request redirected to auth login (${responseUrl.host})`);
  }

  return response;
};

Handle Authentication Redirect by Unregistering Service Worker

File updated:
packages/desktop-client/src/global-events.ts

  • Enhancement: Update the global event handler to unregister the service worker before reloading when an auth redirect occurs.

Replaced:

const unlistenApiFetchRedirected = listen('api-fetch-redirected', () => {
  window.Actual.reload();
});

With:

let authRefreshInProgress = false;

const unlistenApiFetchRedirected = listen('api-fetch-redirected', async () => {
  if (authRefreshInProgress) return;
  authRefreshInProgress = true;
  console.warn('Detected auth redirect – unregistering service worker and reloading.');

  if ('serviceWorker' in navigator && navigator.serviceWorker.getRegistration) {
    try {
      const registration = await navigator.serviceWorker.getRegistration('/');
      if (registration) {
        await registration.unregister();
        console.log('Service worker unregistered.');
      }
    } catch (err) {
      console.error('Error unregistering service worker:', err);
    }
  }
  
  // Use a short delay to ensure unregistration completes before reloading
  setTimeout(() => {
    window.location.reload();
  }, 100);
});

Brief Explanation

  • Issue: Previously, browser-handled redirects prevented proper detection of authentication issues, leading to silent CORS errors.
  • Fix: Forcing redirect: 'manual' in fetch requests allows explicit detection of authentication redirects, prompting service worker removal and page reload to ensure the latest authentication state.

Forked Repository

I've forked Actual Budget with these enhancements for testing:

https://github.com/KenGrinder/actual_CF

I'd appreciate any testing or reviews to confirm no adverse effects are introduced by these changes.

@KenGrinder commented on GitHub (Mar 9, 2025): Ah, I can confirm shortly after my instance stopped working. Full disclosure, I barely know TypeScript and this was mostly created using AI - So this code may be awful/redundant/or incorrect, This still requires the access control allow credentials and allow origin in CF-. I've only been able to test for a few hours in a Docker after deleting the CF_Auth tokens with success, will need some additional testing to confirm. The issue is the worker for the app stays in cache and upon auth expiration it's not able to prompt for the access page while the service is registered in cache. (You can verify this but manually unregistering the service worker via inspect element) - For this code change, Upon auth expiration, it unregisters the worker, and prompts to re-auth through Cloudflare access. I definitely welcome any feedback from someone who actually knows what they are talking about. Here is my proposed changed: ### Updated Fetch Wrapper to Catch Authentication Redirects **File updated:**\ `packages/loot-core/src/platform/server/fetch/index.web.ts` - **Enhancement:** Modified the fetch wrapper to use `redirect: 'manual'` for requests and handle both redirect indicators and opaque redirect responses. #### Replaced: ```typescript import * as connection from '../connection'; export const fetch = async ( input: RequestInfo | URL, options?: RequestInit, ): Promise<Response> => { const response = await globalThis.fetch(input, options); // Detect if the API query has been redirected to a different origin. This may indicate that the // request has been intercepted by an authentication proxy const originalUrl = new URL(input instanceof Request ? input.url : input); const responseUrl = new URL(response.url); if (response.redirected && responseUrl.host !== originalUrl.host) { connection.send('api-fetch-redirected'); throw new Error(`API request redirected to ${responseUrl.host}`); } return response; }; ``` #### With: ```typescript import * as connection from '../connection'; export const fetch = async ( input: RequestInfo | URL, options?: RequestInit ): Promise<Response> => { // Ensure manual redirect mode to catch login redirects const opts: RequestInit = { ...(options || {}), redirect: 'manual' }; const response = await globalThis.fetch(input, opts); // Original request URL and final response URL const originalUrl = new URL(input instanceof Request ? input.url : String(input)); const responseUrl = new URL(response.url); // If redirect occurred to a different origin or an opaque redirect was returned, trigger re-auth flow const differentOrigin = responseUrl.host !== originalUrl.host; if ((response.redirected && differentOrigin) || response.type === 'opaqueredirect') { connection.send('api-fetch-redirected'); // notify the app to reload/authenticate throw new Error(`API request redirected to auth login (${responseUrl.host})`); } return response; }; ``` --- ### Handle Authentication Redirect by Unregistering Service Worker **File updated:**\ `packages/desktop-client/src/global-events.ts` - **Enhancement:** Update the global event handler to unregister the service worker before reloading when an auth redirect occurs. #### Replaced: ```typescript const unlistenApiFetchRedirected = listen('api-fetch-redirected', () => { window.Actual.reload(); }); ``` #### With: ```typescript let authRefreshInProgress = false; const unlistenApiFetchRedirected = listen('api-fetch-redirected', async () => { if (authRefreshInProgress) return; authRefreshInProgress = true; console.warn('Detected auth redirect – unregistering service worker and reloading.'); if ('serviceWorker' in navigator && navigator.serviceWorker.getRegistration) { try { const registration = await navigator.serviceWorker.getRegistration('/'); if (registration) { await registration.unregister(); console.log('Service worker unregistered.'); } } catch (err) { console.error('Error unregistering service worker:', err); } } // Use a short delay to ensure unregistration completes before reloading setTimeout(() => { window.location.reload(); }, 100); }); ``` --- ### Brief Explanation - **Issue:** Previously, browser-handled redirects prevented proper detection of authentication issues, leading to silent CORS errors. - **Fix:** Forcing `redirect: 'manual'` in fetch requests allows explicit detection of authentication redirects, prompting service worker removal and page reload to ensure the latest authentication state. --- ### Forked Repository I've forked Actual Budget with these enhancements for testing: [https://github.com/KenGrinder/actual\_CF](https://github.com/KenGrinder/actual_CF) I'd appreciate any testing or reviews to confirm no adverse effects are introduced by these changes.
Author
Owner

@Nerdtality commented on GitHub (Mar 11, 2025):

Can we get some kind of flag to set this to redirect correctly for CF ZTN?

@Nerdtality commented on GitHub (Mar 11, 2025): Can we get some kind of flag to set this to redirect correctly for CF ZTN?
Author
Owner

@Nerdtality commented on GitHub (Mar 11, 2025):

This seems to work as a temporary solution for CF ZTN Authentication

Image

@Nerdtality commented on GitHub (Mar 11, 2025): This seems to work as a temporary solution for CF ZTN Authentication ![Image](https://github.com/user-attachments/assets/8587f53f-219b-477b-8c35-f6371a906349)
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: github-starred/actual#1879