A Recipe for Authentication Cookies in the Decoupled Frontend & Backend Architecture
Making long-lived cookies work under Safari's ITP
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 }
Considerations
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 theauthToken
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.
Future work
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.
Conclusion
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.