Search code examples
cookiessafarifetchcross-domainsetcookie

Safari doesn't set cookie on subdomain


I've the following setup:

local domain entries in /etc/hosts:

127.0.0.1 app.spike.local
127.0.0.1 api.spike.local

I've created an express server in TypeScript:

const app = express()
app.use(cookieparser())
app.use(
  cors({
    origin: 'https://app.spike.local',
    credentials: true,
    exposedHeaders: ['Set-Cookie'],
    allowedHeaders: ['Set-Cookie']
  })
)

app.get('/connect/token', (req, res) => {
  const jwt = JWT.sign({ sub: 'user' }, secret)
  return res
    .status(200)
    .cookie('auth', jwt, {
      domain: '.spike.local',
      maxAge: 20 * 1000,
      httpOnly: true,
      sameSite: 'none',
      secure: true
    })
    .send()
})

type JWTToken = { sub: string }

app.get('/userinfo', (req, res) => {
  const auth = req.cookies.auth
  try {
    const token = JWT.verify(auth, secret) as JWTToken
    console.log(req.cookies.auth)
    return res.status(200).send(token.sub)
  } catch (err) {
    return res.status(401).json(err)
  }
})

export { app }

I've created a simple frontend:

<button
  id="gettoken"
  class="m-2 p-1 rounded-sm bg-green-600 hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-green-600 focus:ring-opacity-50 text-white"
>
  Get Token
</button>
<button
  id="callapi"
  class="m-2 p-1 rounded-sm bg-green-600 hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-green-600 focus:ring-opacity-50 text-white"
>
  Call API
</button>

<div class="m-2">
  Token Response Status Code:
  <span id="tokenresponse" class="bg-green-100"></span>
</div>
<div class="m-2">
  API Response: <span id="apifailure" class="bg-red-100"></span
  ><span id="apiresponse" class="bg-green-100"></span>
</div>

<script type="text/javascript">
  const tokenresponse = document.getElementById('tokenresponse')
  const apiresponse = document.getElementById('apiresponse')
  const apifailure = document.getElementById('apifailure')
  document.getElementById('gettoken').addEventListener('click', async () => {
    const response = await fetch('https://api.spike.local/connect/token', {
      credentials: 'include',
      cache: 'no-store'
    })
    tokenresponse.innerHTML = response.status
  })

  document.getElementById('callapi').addEventListener('click', async () => {
    const userInfoResponse = await fetch('https://api.spike.local/userinfo', {
      credentials: 'include',
      cache: 'no-store'
    })
    if (userInfoResponse.status === 200) {
      const userInfo = await userInfoResponse.text()
      apifailure.innerHTML = ''
      apiresponse.innerHTML = userInfo + ' @' + new Date().toISOString()
    } else {
      const failure = (await userInfoResponse.json()).message
      console.log(failure)
      apiresponse.innerHTML = ''
      apifailure.innerHTML = failure
    }
  })
</script>

When running the UI on https://app.spike.local and the API on https://api.spike.local both using self certificates and browsing the UI, I can successfully request a token in a cookie and subsequently use this token via cookie being sent automatically for the API call in Chrome and Firefox.

However, on Safari on macOS (and iOS) the Cookie isn't being sent in the subsequent API call.

As can be seen,

  • Cookie settings are SameSite=None, HttpOnly, Secure, Domain=.spike.local.
  • CORS has no wildcards for headers and origins and exposes and allows the Set-Cookie header as well as Access-Control-Allow-Credentials.
  • on client side, fetch options include credentials: 'include'

enter image description here

As said, both API and UI are served over SSL with valid self signed certificates.

When disabling Preferences/Privacy/Prevent cross-site tracking in Safari, everything works fine. But this not an option for this scenario in production.

What am I doing wrong here?


Solution

  • Solved it by changing the TLD to .com instead of .local.

    The hint has been in this comment.