Is there a hole in my logic?
No, but it's arguably over-engineered. If you're using script-based requests (rather than top-level navigation via HTML form submissions) for state-changing requests, all you need to do is add some property to the request that, for a cross-origin request, would make the request no longer a "simple" request (that is, make it something that an HTML form could not generate). A simple example is just adding a custom header:
X-Anti-CSRF: true. Note that there's no secret values or anything here (though the session token is still sent in the cookie, and does still need to be secret). The server does need to make sure that the header is present, but that's all. Another very common way to do this is to require the request type be something like
application/json, which is not allowed in HTML forms. However, it's essential if you do this to verify the complete
Content-Type header, not just that it contains the string "application/json" and/or that the body parses as valid JSON!
The thing is, only on same-origin requests can script add a header (or change the content type to JSON) and not have the browser trigger a CORS Pre-flight. Pre-flight requests do not contain the request itself, they merely ask the server "hey, is this third-party site allowed to make this kind of non-simple request?" and the server will evaluate the various headers in the preflight (usually just the
Origin header) and respond appropriately. Or it will just respond with "400 Bad Request", if the server doesn't need CORS and hasn't configured it. The default state of the server is secure against CORS misconfiguration; CORS can weaken your site's security, but never strengthen it, so leaving it unconfigured is fine (arguably, ideal).
Obviously, if you modify the CORS response to allow any untrusted site to make an authenticated request and add the relevant header / content type, then your CSRF protection totally breaks. Don't do that.
I have read multiple times that I should regularly generate a new CSRF token. Why? If the token is only saved inside the React state it should not be possible for a malicious site to retrieve it.
The traditional anti-CSRF token is essentially a second session token, stored in something that is not a cookie, and tied to your session token in some way (the simplest - though arguably worst - approach is simply to store the two tokens as a pair on the server, in a DB or similar). This means you want to consider all the usual session fixation attacks. The anti-CSRF token needs to change whenever the user's session becomes authenticated, or else an attacker on e.g. a shared machine (at a school, library, or internet cafe) could note the anti-CSRF token, abandon the machine, wait for a victim to log in, and then CSRF them (because the anti-CSRF token hadn't changed). Giving each account a unique but fixed token avoids that specific problem, but has the problem that if an attacker ever sees a user+anti-CSRF-token pair, even long after the fact, they can still use that token. Much better if the token is tied to the session rather than the user, and as such is unique to each session.
Or just use an approach that doesn't require an anti-CSRF token, such as the ones above.
Lastly is it possible to mitigate CSRF by putting frontend and backend on different origins (https://example.com and https://api.example.com)? My JWT token would be saved as a cookie on the frontend. Therefore, if a malicious site forges a request to https://api.example.com/delete the browser would not append the JWT cookie because it is saved on a different page.
Yes, but not the way you describe it. The approach you describe (where the cookie is scoped to the frontend-only domain) wouldn't work at all - that is, it wouldn't even be insecure, just non-functional - because the backend would never see the session token at all and would reject every request as unauthenticated. There are ways around this. Using a common domain ("example.com", in your examples) means you can scope the cookie to the entire domain including subdomains; this is less secure (what if there's another page on the same domain with an XSS flaw, or similar?) but reasonably common. Alternatively, set the cookie for the back-end domain rather than the front-end domain. This may fail on some browsers (especially if there is no common domain name between the two) if they are configured to reject third-party cookies (which are often used for tracking), but at least it would work much of the time.
However, neither of those latter two provide any CSRF protection at all. Cookies are attached based on the destination of the request, not the origin. If a request is bound for https://api.example.com and there's a cookie for https://api.example.com, the browser will include that cookie (modulo a whole bunch of caveats). This is, after all, the core design choice behind why CSRF works at all. You'd still need another form of anti-CSRF protection.
Also, note that - if you're using script-initiated API requests, as your design calls for - you will need to configure CORS on the backend to allow authenticated requests from the front-end's origin. If you use a custom header or whatever for CSRF protection, you'll need to allow the front-end origin (but no other origins!) to send such things too.
Note that simply checking the value of the
Origin header on requests is NOT adequate protection, unless you reject origins other than your front-end's origin. While there is no way for an attacker to make a browser spoof the Origin header in a request, there are ways to omit it, at least on some browsers. Chrome and other Blink-based browsers send Origin on most requests, but this is non-standard behavior and other browsers that merely follow the standard don't always do this, or perhaps do it in different contexts.
There are other options too. The cookie
samesite flag is relatively new (compared to other cookie flags), but supported by all modern browsers (IE never got support but is also officially deprecated). Samesite has a LOT of flaws and limitations that were done to try and maintain compatibility and add minimal work for site owners, but it does protect against the common CSRF attacks. The idea is simple: if a cookie is created with this flag set to "lax" (now the default on modern browsers) or "strict", the browser will not consider only the destination of the request when choosing what cookies to send. Instead, it will also consider the source - the origin - of the request, and not attach the cookie if that origin is different enough that it thinks this is a different "site". If you want cross-site cookies to work, you actually need to manually specify
samesite=none on the cookie at creation!
There are also other types of requests that don't include cookies. For example, CORS requests default to not including them (though it's easy to request that they be sent), and CORS pre-flight requests never contain cookies.