Your design is, broadly speaking, good. While you shouldn't count on keeping the access token in a JS closure being sufficient to avoid its exposure through XSS - if nothing else, an attacker with XSS execution can always just hit the refresh endpoint to directly get a new access token for that user - it shouldn't hurt anything. It does mean that different tabs will have different access tokens, and that refreshing the page will require refreshing the access token, but those are common user inconveniences of SPAs in general (speaking purely as a user, please at least make Back and Forward work correctly!). You could store the access token in session storage - it makes no real difference in security - and then it would persist across refreshes and tabs, but you'd still have the refresh logic needed.
You could also improve user experience a little by setting a timeout and refreshing the access token shortly before it expires, ensuring that the user doesn't have to wait for an extra round trip every few minutes when they request data (this delay should be very small, but surprisingly short delays still impact UX). You'd still need to implement the "refresh if the token is expired/invalid" since preemptive refresh won't always work (e.g. if the user put the computer to sleep). This has no security impact at all; it's purely a UX thing, which I'm mentioning because so many SPAs end up being bad UX.
On to your questions:
However, in an application where the resources and the authentication are different API end points on the same exact domain, the refresh token is going to be added to every API call.
This is the use case for the relatively uncommon Path
cookie parameter. While it has no security impact - if you don't fully trust all your servers, DO NOT put them on the same domain, there's no way to make that secure - you can avoid sending the cookie to endpoints that don't use it.
With that said, I wouldn't worry. Refresh tokens are shorter than JWTs or many other cookies - 128 bits of randomness, even hex-encoded rather than base64, is only 32 bytes for the cookie value - and most sites have far more cookie bloat than that. So while it makes sense to use the Path
parameter here, it's hardly a disaster if you don't.
Since the refresh tokens are long living and are going to be sent with every API request, isn't that a security issue also?
Not really. We already talked about why you don't need to send it on every request, and also why it shouldn't matter even if you do. The "long lived" thing is just inherent in how refresh tokens work, and it's not unique to them; random session tokens, API keys, and of course credentials like passwords themselves are all also long-lived. There are some best practices for slight improvements in security, though:
- Rotate the refresh token every time it is used. That is, validate the token, and then generate both a new access token and a new refresh token and return them to the client (presumably in the body and via
Set-Cookie
header respectively), and store the new refresh token on the server (replacing the old one). This can cause problems sometimes - for example, if the user has two tabs open to your site, and they try to refresh simultaneously, you have a race condition where both might send the refresh old token but only one of them receive a new access token - but there are ways around that (e.g. don't immediately invalidate the old refresh token; keep it around for a few seconds).
- Hash the refresh token before storing it on the server, and when validating it from the client. Unlike passwords, you can use a single-round unsalted fast hash (e.g. a member of the SHA2 or SHA3 families) here; assuming your refresh tokens are generated with an appropriate level of entropy, there's no need to worry about guessing, brute-forcing, or collisions between tokens. Hashing prevents exposing other peoples' refresh tokens in the event of an information disclosure breach (e.g. read-only SQLi, stolen DB backup, snooping admin), and also inherently prevents string comparison timing attacks.