Your approach is probably safe enough, assuming that when you say "If the entire JWT is present..." you mean present in the header, not (just) in a cookie. Similarly, you say
It will then set a cookie (HttpOnly, secure) with the JWT, and return said JWT.
Presumably you mean return the JWT in the response body, as well as in the Set-Cookie header? And presumably your API does this regardless of the client (API client vs. webapp client)? You need to be specific about these things.
However, your approach is complicated, which greatly increases the risk of a vulnerability. Keep it simple. I recommend one of the following approaches instead:
- Just transmit the full JWT in a header in both cases. This necessitates storing it in JS-readable storage in the webapp, but lets you do away with cookies entirely (or at least, not for the JWT; you might use them for other things), eliminates any risk of CSRF with any client, and means there's only one canonical location where the JWT is expected to be. The increased risk from having the JWT in JS-readable storage is really quite minimal; JWTs are usually not revocable (there are ways to make them so, but it adds complexity and loses some of the benefits of using JWTs at all so generally they are completely stateless and hence irrevocable), and irrevocable tokens need to have very short lifetimes (a few minutes is typical), so the risk from them being stolen is minimal. In most cases, the attacker can simply keep making requests through the victim's browser without any need to steal the token, whether it's visible to script or not.
- Use cookies for the JWT, plus some CSRF protection. API clients are entirely capable of both receiving and sending cookies, and while most non-browser ones won't do so automatically the way browsers do, this is quite trivial to work around for an API client developer. This once again means there's only one place the JWT will ever be looked for in a request, and simplifies your request authorization logic. You do need to have CSRF protection, at least for state-changing requests; the idea of putting a value in the JWT and expecting it in a header is valid, but you could also expect the value in the request body, or simply require the presence of a given custom header without requiring a secret value (e.g.
X-CSRF: false); assuming you don't have a completely broken CORS configuration, that is enough to prevent CSRF (browsers won't let script send custom headers cross-origin without a CORS pre-flight explicitly granting that origin the permission to send that header) and is very simple to add at the client and to check at the server.
With that all said, there are possibly other issues you need to consider. What are you planning to do about session lifetimes? Unless you're planning to include a revocation check in your JWT validation logic - which makes JWTs not entirely stateless, losing one of their performance advantages - you will have no way to invalidate JWTs before they expire, so their expiry must never be far away (that is, their lifetime must be short). To avoid a bad user experience, you'll want a "refresh token" that is longer-lived but not checked on every request (generally it's only checked, and often only sent, on requests to a specific endpoint such as /refresh). The refresh token is unique to each user session, and can be used to obtain a new valid JWT even if the client's old JWT has expired (assuming that the refresh token hasn't been revoked, e.g. by the user logging out or an administrator disabling their account). Refresh tokens absolutely must be revocable, so they're generally opaque random tokens rather than JWTs or other typically-stateless tokens. They're longer-lived, so it's more important to keep them secret even if the client is compromised; a Secure + HttpOnly cookie is a good choice.
If you go with anti-CSRF tokens (transmitted in the JWT, or elsewhere), make sure they are unique per user - ideally unique per login session - and unpredictable. A predictable (either because it's short/low-entropy, consistent, or generated from an insecure PRNG) anti-CSRF token can't reliably prevent the attacker from making CSRF attacks (though if the token is additionally transmitted in a header, the need for CORS pre-flights can still prevent CSRF even if the value to be sent is known to the attacker).