Accessing document.cookie returns empty string even though cookies are listed in developer tools with httpOnly flag set to false

Viewed 3705

Sometimes*, when accessing document.cookie in the login page I get an empty string even though:

  1. cookies are listed in the Chrome and Firefox developer tools,
  2. httpOnly flag of cookie I'm interested in is set to false,
  3. path of cookie I'm interested in is set to '/'.

Desired behaviour

My React single page application (SPA) has a login page which contains a <form /> element for sending login credentials to the backend. When the response from the backend is received and the authentication was successful, I check whether the authentication cookie has been set, properly. If that's the case a redirect will be triggered that shows the content for logged in users.

Actual behaviour

Unfortunately, in like 15% of the login attempts, document.cookie returns an empty string which prevents the redirection and keeps the user on the login page. Pressing F5 doesn't do the trick but when manually replacing a path of the url after a successful login request (e.g. updating 'www.website.tld/login' to 'www.website.tld/start') the user gets forwarded to the desired page which is for logged in users, only.

I'm not able to reproduce the error manually. It just seems to happen randomly. But when it occurs and I have a look into the developer console I can see all the cookies from the backend (set correctly).

Additional information

  • a django server is running in the backend
  • the desired cookie is set with response.set_cookie('key', 'value', secure=False httponly=False, samesite='strict')
  • JS libs (axios, react-router)

Related:

Login page (JSX)

    import React, { useState } from "react";
    import { Redirect } from "react-router-dom";
    import axios from "axios";

    /**
     * We're using cookies.js to read cookies.
     * Source: https://github.com/madmurphy/cookies.js
     */
    function hasItem(sKey) {
      return new RegExp(
        "(?:^|;\\s*)" +
          encodeURIComponent(sKey).replace(/[\-\.\+\*]/g, "\\$&") +
          "\\s*\\="
      ).test(document.cookie);
    }

    export const LoginPage = () => {
      const [isAuthenticated, setIsAuthenticated] = useState(false);
      const [username, setUsername] = useState("");
      const [password, setPassword] = useState("");

      function handleSubmit(e) {
        e.preventDefault();

        function onSuccess(response) {
          // handle response
          // [...]

          // sometimes console.log(document.cookie) returns empty string
          if (hasItem("auth_cookie")) {
            setIsAuthenticated(true);
          } else {
            console.warn("Cookie not found!");
          }
        }

        function onFailure(error) {
          // handle error
        }

        const conf = {
          headers: new Headers({
            "Content-Type": "application/json; charset=UTF-8",
            Origin: window.location.origin
          })
        };

        axios
          .post("/api/login/", { username, password }, conf)
          .then(response => {
            onSuccess(response);
          })
          .catch(error => {
            onFailure(error);
          });
      }

      if (isAuthenticated) {
        return <Redirect to="/start" />;
      }

      return (
        <div className="login-page">
          <form
            name="login-form"
            method="post"
            onSubmit={e => handleSubmit(e)}
            action="api/login"
            target="hiddenFrame"
          >
            <iframe className="invisible-frame" src="" name="hiddenFrame" />
            <div>
              <label htmlFor="username">Email</label>
              <input
                name="username"
                type="text"
                onChange={e => setUsername(e.target.value)}
              />
            </div>
            <div>
              <label htmlFor="password">Password</label>
              <input
                name="password"
                type="password"
                onChange={e => setPassword(e.target.value)}
              />
            </div>

            <button type="submit">Submit</button>
          </form>
        </div>
      );
    };

Routing (JSX)

    import React from "react";
    import { Route, Redirect } from "react-router-dom";

    const RootLayout = () => {
      return (
        <div className="root-layout">
          <Switch>
            <PublicRoute path="/login" component={LoginPage} />
            <PrivateRoute path="/" component={App} />
          </Switch>
        </div>
      );
    };

    /**
     * Component that handles redirection when user is logged in already
     */
    const PublicRoute = ({ component: ChildComponent, ...remainingProps }) => {
      let isAuthenticated = hasItem("auth_cookie");
      return (
        <Route
          render={props =>
            isAuthenticated ? <Redirect to="/" /> : <ChildComponent {...props} />
          }
          {...remainingProps}
        />
      );
    };

    /**
     * Component that handles redirection when user has been logged out.
     * E.g. when authentication cookie expires.
     */
    const PrivateRoute = ({ component: ChildComponent, ...remainingProps }) => {
      let isAuthenticated = hasItem("auth_cookie");
      return (
        <Route
          render={props =>
            !isAuthenticated ? (
              <Redirect to="/login" />
            ) : (
              <ChildComponent {...props} />
            )
          }
          {...remainingProps}
        />
      );
    };

    const App = () => (
      <Switch>
        <Route exact path="/" render={() => <Redirect to="/start" />} />
        <Route exact path="/start" component={StartPage} />
        <Route exact path="/blog" component={BlogPage} />
        {/*...*/}
      </Switch>
    );

* I know, that's probably not how a post should start...

1 Answers

You are running into an issue with sameSite cookies. See SameSite cookies explained for an explanation:

If you set SameSite to Strict, your cookie will only be sent in a first-party context. [...] When the user is on your site, then the cookie will be sent with the request as expected. However when following a link into your site, say from another site or via an email from a friend, on that initial request the cookie will not be sent. This is good when you have cookies relating to functionality that will always be behind an initial navigation, such as changing a password or making a purchase, but is too restrictive for promo_shown. If your reader follows the link into the site, they want the cookie sent so their preference can be applied.

Now you have at least two options:

  • Recommended: Keep samesite=strict and refactor your client code. The frontend does not need access to the auth cookies at all and thus you could set httponly=True. Then introduce a backend API that validates a cookie based on requests from the client code. That gives you the added advantage to be less vulnerable to XSS attacks as the frontend code has no access to the auth cookie.
  • Not recommended: Set samesite to none or lax.
Related