A Recipe for Authentication Cookies in the Decoupled Frontend & Backend Architecture

3 min read Original article ↗

With the recent changes in Safari's Intelligent Protection Tracking, it has become harder to keep authentication cookies saved for long periods of time. This recipe shows a way of how to keep authentication working beyond the 7-day expiration window.

To bypass the 7-day cap, the cookies must be classified as first-party cookies. Safari 17 introduced changes to Safari's first-party cookie classifier. With the new changes, simply setting the cookie on a domain that is a subdomain of the URL seen in the browser's URL box is not enough. The IP addresses, to which the subdomain and the domain resolve, must also be in the same subnet.

This is difficult in a Decoupled Frontend & Backend Architecture since the frontend is typically served by a globally distributed DNS with an unpredictable IP address and the subdomain is served by a load balancer in one of the regions where the API is deployed.

The solution to this is as follows: add a dynamic endpoint (Netlify Functions, CloudFront Functions, etc.) to your frontend that sets the authentication cookie on the frontend domain and use that cookie on your backend subdomain. Let's say your frontend is served on example.com and your backend is served on api.example.com. Then, to set your authentication cookie, in your client code:

example.com/set-auth-token endpoint:

  • Request:

fetch("https://example.com/set-auth-token", {
  method: 'PUT',
  headers: {
      'Content-Type': 'application/json',
  },
  body: JSON.stringify({ authToken }),
});
  • Response headers:

Set-Cookie:
  __Secure-authToken=${authToken}; 
  Max-Age=34560000; 
  Domain=example.com; 
  Path=/; 
  HttpOnly; 
  Secure; 
  SameSite=Strict

Then for any credentials: 'include' requests from your client code to your API, the cookie will be passed through:

api.example.com/is-authed endpoint:

  • Request:

fetch(`https://api.example.com/is-authed`, {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
  },
  credentials: 'include',
})
  • Headers:

Cookie:
  __Secure-authToken=${authToken}
  • Response:

{ "isAuthed": true }
  • We're using a __Secure, not a __Host cookie. This is because a __Host cookie is domain-locked to only one domain. We want the authentication cookie to be shared across all of our subdomains.

  • Since the authentication cookie is now shared across all subdomains, make sure you only run trusted code on your subdomains.

  • We use SameSite=Strict to protect against CSRF.

  • This matches the security model of username/password authentication where JavaScript on the page must be trusted. If you're using OpenID, it would be best to use a POST redirect to the example.com/set-auth-token endpoint in order to avoid exposing the authToken to JavaScript or DOM.

  • We can't use LocalStorage or IndexedDB because they have the same 7-day cap since ITP 2.3.

  • Use this at your own risk.

  • If your main domain is www.example.com, it's unclear whether this will work. I suspect that it might, you will probably need to set the cookie domain to .example.com but this hasn't been tested.

This recipe lets you set an authentication cookie that lasts beyond the 7-day cap on Safari, extending up to 400 days. Even though Safari is the only browser that's this restrictive with cookie durations, its market influence sets the standard for all web apps to follow.

If you find a mistake, send me an email!

Thanks for reading.

Discussion about this post

Ready for more?