0

There is really not that great information on what the best practices are for auth in SPA/API solutions. Most of them just say use JWTs and auth code flow in the SPA. There is a ton of information regarding auth in a SPA where you are requesting third party APIs like MS Graph or Google Calendar, but NOT API's that are your own. Most solutions I see today in the real world consists of a 1 to 1 mapping between a SPA and a API. So what are the best practises for these types of solutions? IETF has a draft, recommending BFFs, but I don't see the point of that if you own the API yourself.

I have a React SPA and a .NET API, where the user will login using AD FS. It is currently using Implicit Flow, which I am swapping out for Auth Code Flow.

I am exchanging the auth code for the tokens in the backend (using client secret as well), but sending back a cookie to the SPA and completely discarding the tokens that came from AD FS. I see no point of keeping the tokens for now. The flow goes like this:

  1. User opens SPA, generate code verifier + state, gets redirected to the sign in page /oauth/authorize from AD FS with a code challenge.
  2. User signs in and is redirected back to the frontend.
  3. Frontend checks URL for auth code + state, verifies and calls the backend /api/authenticate, with the auth code + the code verifier.
  4. Backend calls the /oauth/token endpoint with auth code, code verifier and the client secret (stored only in backend) and is returned the token. I then get the claims from the token, and use that as ClaimsIdentity and sign the user in with a cookie (basically map the JWT claims to be used as claims in the cookie). Discarding the tokens at this point, as I have no need for them.
  5. SPA sends all requests to the API with cookies, no tokens in the browser.

Cookie is same site=lax, httponly and secure. Also have CORS rules setup.

When signing in the user, I also create a "custom token" which is saved to the db to have session management. I save an initial expire time (20 minutes in the future, can be extended), and absolute expire time (8 hours). On every authenticated request, if the expire time is halfway through, I will extend it up to 20 minutes or up to the absolute expire time if that is closer. If the absolute time is reached, the user is redirected back to AD FS to sign in.

I think this is the best approach for SPAs/APIs. However, I could store the refresh token from AD FS, and on every extension of the expire time I could do a refresh token request to AD FS, to handle any claims changes. Other than that, think this solution is better than having access/refresh tokens in the browser. Any thoughts on this? Or is there a better way? This of course requires more custom code in both SPA and API, at least more than just using libraries like MSAL. However, I don't think in SPA/API solutions where you own both, using JWT's should be recommended. Also setting up a BFF seems pointless.

1
  • A warm welcome to the community. Well rate limiting, refresh tokens and auth for every essential API endpoint would be a good start. I think OWASP has some good resources about that kind of security for API endpoints. Feb 25 at 13:08

1 Answer 1

2

What you're describing, aside from the use of a single-page app for the client, is broadly how web apps have worked since their inception: there's an authentication process (in this case SSO via OAuth2 Authorization Code flow, but that detail is mostly irrelevant), after which some form of session token is generated by the backend and sent to the client as a cookie + stored on the server, and then the client uses that cookie to authenticate and authorize further request until logout or expiration.

This is a secure and relatively straightforward process, although there are some important considerations that you might want to take into account. In no particular order (and note that this is not an exhaustive list):

  • Token generation: how do you create your "custom token", and how much entropy does it have? It is essential that an adversary not be able to predict valid tokens for other people / to gain increased privileges, including by examining or modifying their own token. The standard recommendation is to use a cryptographically secure [pseudo-]random number generator to create 128 (or more) bits (16 bytes), which can then be encoded in a textual format such as hex or base64. If you're using a token that isn't fully random, ensure it is tamper-proof without high-entropy secrets that an attacker can't access (e.g. JWTs generally qualify, though they have their own downsides).
  • Session fixation: what do you do in the event that a client logs in while already having a cookie? It is essential whenever a user's identity or permissions change that they receive a new cookie rather than reuse one, even if the reused one has not expired or been revoked (e.g. logged out). It is a very good idea to do this for all authentications, even when it's the same user with the same privileges again. Failure can result in situations where a token is planted or accidentally reused (e.g. on a shared computer) and a previous user is able to access a new user's session.
  • SSO session and account management: your current design only considers the user's account with the SSO provider at the time they log into your app. If the user is subsequently blocked or terminated from the SSO provider (e.g. it's an employee account and they leave the company), or their permissions change (e.g. they are promoted to administrator), your app won't see the change until the user logs in again (which might be up to 8 hours of active session use later). Similarly, your app can't detect if the user logs out of the SSO provider; if they don't also log out of your app, their session there will last until it expires. There are mitigations for these risks, though frankly, the use of such a short timeout (20 minutes, assuming you don't have anything polling to keep the session alive) is already a partial mitigation since the main risk is to long-lived sessions.
  • CSRF prevention: the use of cookies invites the risk of cross-site request forgery. The same-site flag mitigates this to an extent, but same-site is not always sufficient; it's much too broad (different ports, protocols, and subdomains can all be considered the same site even though they are all different origins) and thus e.g. a vulnerability in a less-secure marketing homepage can potentially be used to attack active sessions. CORS configuration is not a mitigation here unless the requests inherently require CORS pre-flights; if e.g. the requests are required to be in JSON format but don't strictly validate the Content-type header, CSRF is still possible. Also, CORS of course provides no protection if it considers the origin trusted, e.g. if your app is hosted at app.example.com and the API at api.example.com, but your CORS config trusts all *.example.com subdomains, then a less-secure www.example.com marketing page could forge requests to api.example.com successfully. Consider carefully whether you need additional CSRF protection (or would benefit from defense-in-depth).
  • Token storage on the server: although your token max lifetimes are short enough that it matters little here, hashing the session token before storing it in the DB (and again when looking it up in the DB) can prevent some attacks where an unauthorized read of the DB (either online or via e.g. a compromised backup) occurs. An unsalted single round of a fast secure hash (e.g. the SHA2 or SHA3 families) is sufficient, assuming the token itself is high-entropy as it should be.
  • OAuth2 security considerations: since you're moving from implicit flow, you (or your SSO provider) are hopefully already strictly validating the redirect URI, as this is essential for implicit flow. With authorization code flow, either the state parameter or PCKE can mitigate stolen auth codes in some scenarios, but you should use both (with unpredictable secrets) and continue to be strict about allowed redirects. Similarly, your client_secret should of course be unique, unpredictable, and high entropy.
  • Scalability: the most significant downside of using session tokens that are stored on the back end is that it adds latency and load to every request, due to (in this case) the additional DB query. There are ways to mitigate this - e.g. only query the DB when seeking to perform an access-controlled action and perform the token authorization as part of the same query that fetches the data / makes the change, or have a short-lived cache of valid tokens in server memory directly - and in some cases it doesn't even matter (plenty of very large-scale sites use backend-stored session tokens), but you should consider the tradeoffs beyond just what is most secure.
1
  • Thanks! I'll follow up on your points!
    – ryansan
    Mar 6 at 1:33

You must log in to answer this question.

Not the answer you're looking for? Browse other questions tagged .