27

I am using Express.js server. With cookie-parser I have opened this endpoint

app.get("/s", (req,res) => {
    res.cookie("bsaSession", req.session.id)
    res.send("set cookie ok")
})

When I manually use the browser to http://localhost:5555/s where I have the website running the browser debug console shows that the cookie have been applied.

enter image description here

But when I use fetch API to do the equivalent, it does not set the cookie.

  async trySetCookie()
  {
    await fetch("http://localhost:5555/s",{
       method: 'GET',
       credentials: 'same-origin'
    })
  }

Why?

5argon
  • 3,683
  • 3
  • 31
  • 57
  • 2
    "it does not set the cookie" --- how do you know that? Has the browser sent the cookie in the request headers? – zerkms Mar 10 '17 at 04:09
  • 1
    I looked in the Safari debug console > Storage > Cookies. When I click the button that call `trySetCookie()` the list does not change. But when I go to that page directly the list has new cookies added immediately. – 5argon Mar 10 '17 at 04:11
  • 1
    So, do you see the `Set-Cookie` in the response headers? Does that script run on the same port? – zerkms Mar 10 '17 at 04:13
  • 1
    This may just be a case of Safari debug console not updating its display immediately when an Ajax call was done. I'd suggest you test the cookie's existence with code. Just do `console.log(document.cookie)` after your fetch has completed. – jfriend00 Mar 10 '17 at 04:17
  • I have found one more clue, in both case `Set-Cookie` definitely shows the cookies to be set, but in the `fetch` case it always have `HttpOnly` following the cookie string. That might be what prevent the browser from setting the cookie? I have tried adding `{httpOnly : false}` option on the server side but `fetch` still has that `HttpOnly` regardless. – 5argon Mar 10 '17 at 04:27
  • Also, the page that I have the button to trigger `fetch` is not in `http://localhost:5555/` but in `http://localhost:3000/`. That might be what triggered CORS related things.. – 5argon Mar 10 '17 at 04:52
  • I have found the solution, and yes @jfriend00 I found out after my code works that Safari did require a refresh to update the cookie list if you get the cookie from scripts. – 5argon Mar 10 '17 at 05:02

2 Answers2

47

I have found the solution. The core of this problem being that my button to trigger the fetch is on http://localhost:3000/. The server is on http://localhost:5555/ (I am simulating real environment on my own machine)

The problem is that this fetch call

  async trySetCookie()
  {
    await fetch("http://localhost:5555/s",{
       method: 'GET',
       credentials: 'same-origin'
    })
  }

Without credentials, the browser cannot send or receive cookies via fetch (https://developer.mozilla.org/en-US/docs/Web/API/Request/credentials)

With credentials as same-origin I can see the cookies coming from the server in the Set-Cookie response header, but nothing is being stored in the browser. One strange thing is that this response always have HttpOnly tagged after the cookie string regardless of my {httpOnly : true/false} settings on the server. In the case of manually using the browser to the page to do GET request, HttpOnly is being respected as usual, and the cookies are set.

So the solution is to set credentials as include to allow cross-origin cookie sending.

  async trySetCookie()
  {
    await fetch("http://localhost:5555/s",{
       method: 'GET',
       credentials: 'include'
    })
  }

Also, on the server side you need to allow a particular origin manually with new headers:

app.get("/s", (req,res) => {
    res.cookie("bsaSession", req.session.id, {httpOnly:false})
    res.header('Access-Control-Allow-Origin', 'http://localhost:3000')
    res.header('Access-Control-Allow-Credentials','true'
    res.send("set")
})

Not doing this results in

XMLHttpRequest cannot load http://localhost:5555/s. Cannot use wildcard in Access-Control-Allow-Origin when credentials flag is true.

But the cookie will be set regardless of this error. Still nice to include that header to silence the error.

If you are using cors middleware for Express it is even easier. You can just use these options

var corsOptions = {
  origin: 'http://localhost:3000',
  credentials:  true
}

app.use(cors(corsOptions))

And of course credentials: 'include' is still required at the client side.

5argon
  • 3,683
  • 3
  • 31
  • 57
  • 2
    For first Authentication in CORS case, parameter {credentials: 'include'} is also needed. Then the browser will set the server returned cookie automatically. – zhaofeng-shu33 Feb 03 '19 at 13:17
  • 1
    Brah, u da real mvp. Had been struggling with that for a whole day. You deserve a holiday to celebrate your skills. – daydreamer Jan 28 '22 at 12:47
  • I wish either fetch or the browser at least gave a simple warning I spent a few hours on it too :( Thanks however. – aderchox Apr 12 '22 at 19:14
  • Why httponly is false? If it is true, the cookie will not be set? – Konstantin Vahrushev Sep 13 '22 at 09:31
  • @KonstantinVahrushev with HttpOnly set to true, the cookie will still be set. However, it will not be accessible to the browser's javascript. This is what you want for certain things like a session id--there's no need for the client side code to ever access that data. – kevlar Jul 20 '23 at 23:17
2

5argon's solution was great for me otherwise, but I had to set origin in express cors to true. So in backend:

app.use(
  cors({
    origin: true,
    credentials: true,
  })
);

And in fetch:

fetch("http://localhost:5555/s", {
    method: 'GET',
    credentials: 'include'
})
D. Schreier
  • 1,700
  • 1
  • 22
  • 34
John
  • 29
  • 1
  • warning for people using NextJS: it apparently doesn't like the "origin: true" neither "origin: '*' " being set. John's answer should work in various use cases, though – daydreamer Jan 28 '22 at 12:49