48

I'd like to add a "remember me" checkbox option before logging in.

What is the best way to securely store a cookie in the user's browser?

For example, Facebook have their "remember me" checkbox so that every time you enter facebook.com you are already logged in.

My current login uses simple sessions.

Scott Arciszewski
  • 33,610
  • 16
  • 89
  • 206
Karem
  • 17,615
  • 72
  • 178
  • 278
  • You may take a look at https://github.com/delight-im/PHP-Auth and its source to see how to implement a *secure* "remember me" feature. Basically, just store some *very long* (i.e. much entropy) string of random data in a cookie. When the user visits your page, check that "token" against your database where you track these tokens. If the token is valid, authenticate the user. – caw Jul 13 '16 at 00:01

1 Answers1

66

Update (2017-08-13): To understand why we're separating selector and token, instead of just using a token, please read this article about splitting tokens to prevent timing attacks on SELECT queries.

I'm going to extract the strategy outlined in this blog post about secure long-term authentication since that covers a lot of ground and we're only interested in the "remember me" part.

Preamble - Database Structure

We want a separate table from our users' table that looks like this (MySQL):

CREATE TABLE `auth_tokens` (
    `id` integer(11) not null UNSIGNED AUTO_INCREMENT,
    `selector` char(12),
    `token` char(64),
    `userid` integer(11) not null UNSIGNED,
    `expires` datetime,
    PRIMARY KEY (`id`)
);

The important things here are that selector and token are separate fields.

After Logging In

If you don't have random_bytes(), just grab a copy of random_compat.

if ($login->success && $login->rememberMe) { // However you implement it
    $selector = base64_encode(random_bytes(9));
    $authenticator = random_bytes(33);

    setcookie(
        'remember',
         $selector.':'.base64_encode($authenticator),
         time() + 864000,
         '/',
         'yourdomain.com',
         true, // TLS-only
         true  // http-only
    );

    $database->exec(
        "INSERT INTO auth_tokens (selector, token, userid, expires) VALUES (?, ?, ?, ?)", 
        [
            $selector,
            hash('sha256', $authenticator),
            $login->userId,
            date('Y-m-d\TH:i:s', time() + 864000)
        ]
    );
}

Re-Authenticating On Page Load

if (empty($_SESSION['userid']) && !empty($_COOKIE['remember'])) {
    list($selector, $authenticator) = explode(':', $_COOKIE['remember']);

    $row = $database->selectRow(
        "SELECT * FROM auth_tokens WHERE selector = ?",
        [
            $selector
        ]
    );

    if (hash_equals($row['token'], hash('sha256', base64_decode($authenticator)))) {
        $_SESSION['userid'] = $row['userid'];
        // Then regenerate login token as above
    }
}

Details

We use 9 bytes of random data (base64 encoded to 12 characters) for our selector. This provides 72 bits of keyspace and therefore 236 bits of collision resistance (birthday attacks), which is larger than our storage capacity (integer(11) UNSIGNED) by a factor of 16.

We use 33 bytes (264 bits) of randomness for our actual authenticator. This should be unpredictable in all practical scenarios.

We store an SHA256 hash of the authenticator in the database. This mitigates the risk of user impersonation following information leaks.

We re-calculate the SHA256 hash of the authenticator value stored in the user's cookie then compare it with the stored SHA256 hash using hash_equals() to prevent timing attacks.

We separated the selector from the authenticator because DB lookups are not constant-time. This eliminates the potential impact of timing leaks on searches without causing a drastic performance hit.

Scott Arciszewski
  • 33,610
  • 16
  • 89
  • 206
  • 2
    Excellent work. Is it worth adding the expiry check in your code? Do you regenerate the token elsewhere in your code (e.g. 1 in 10 page loads)? – rybo111 Jun 13 '15 at 10:19
  • Argh! This looks great and all, but I cannot get `hash_equals` to return true using any of these custom functions: https://php.net/hash_equals#115664 – rybo111 Jun 13 '15 at 18:13
  • If you need an in-PHP implementation: https://github.com/sarciszewski/php-future – Scott Arciszewski Jun 14 '15 at 19:26
  • You regenerate the token after it's used once. – Scott Arciszewski Jun 14 '15 at 19:32
  • What if the user checked remember me and leaves their session open for the duration? Do you regenerate it at any point in the same session? – rybo111 Jun 14 '15 at 19:57
  • No, the remember me is to re-authenticate them when their session expires (i.e. when you turn your computer back on tomorrow). Short-term session authentication should just be a session identifier (i.e. use what PHP gives you). – Scott Arciszewski Jun 14 '15 at 21:25
  • @ScottArciszewski is your method safe? I create a clone of the cookie generated by Chrome and put it in Firefox and the user have logged in as chrome. So is it safe? – AgainMe Dec 11 '16 at 12:41
  • 2
    @AgainMe Safe against any reasonable threat model. If someone can intercept/clone cookies (malware), there's nothing the server can or should do about that risk. See also: https://paragonie.com/blog/2016/03/client-authenticity-is-not-server-s-problem – Scott Arciszewski Dec 12 '16 at 05:03
  • Why `selector` and `token`?. Is token is not enough – Naveen DA Aug 07 '17 at 15:09
  • @NaveenDA There is a dedicated article that addresses this: https://paragonie.com/blog/2017/02/split-tokens-token-based-authentication-protocols-without-side-channels – Scott Arciszewski Aug 13 '17 at 20:29
  • Thanks @Scott Arciszewski now i got it – Naveen DA Aug 14 '17 at 06:09
  • the last if statement in "Re-Authenticating On Page Load" doesn't work for me, can anyone help? – Termatinator Mar 11 '18 at 16:40
  • What version of PHP are you running? – Scott Arciszewski Mar 12 '18 at 16:46
  • What does this line mean: // Then regenerate login token as above. Do I have to create selector and authenticator again? – Kshitij Kumar Jun 09 '18 at 03:54
  • 1
    Yes, they should only be used once. – Scott Arciszewski Jun 11 '18 at 17:07
  • You should not query database every time you get a cookie.You should verify the cookie is "signed" by you. Otherwise you can easily get flooded. – spajak Jul 10 '19 at 13:44
  • That's a non-concern, to be honest. The only time this "flooding" happens is when people are using ultra-cheap shared hosting instead of running their own infrastructure. But if you "must" do this, be sure to use [PASETO](https://paseto.io) instead of, say, JWT. – Scott Arciszewski Jul 10 '19 at 20:02
  • I used this function, it works on hostgator. and I know its not a strong as the others.. but its good for my small purposes $selector = base64_encode(openssl_random_pseudo_bytes(9)); $authenticator = openssl_random_pseudo_bytes(33); – hamish Jul 14 '19 at 12:19
  • @hamish Why doesn't HostGator let you upgrade to PHP 7? Surely they'll upgrade your PHP version if you open a support ticket? If not, install [random_compat](https://github.com/paragonie/random_compat) and pretend you're on PHP 7. – Scott Arciszewski Jul 16 '19 at 12:10
  • 1
    I like this approach. The only thing I did differently was, rather than relying solely on $_SESSION['userid'], I also set a $_SESSION['selector'] to check against auth_tokens. This way I'm able to invalidate any active sessions (e.g. in the case of a password reset) without having to wait for the PHP session to expire. – Brian Stanback Nov 09 '19 at 06:18
  • Great solution, thank you! One addition I made to the Re-Authenticating On Page Load section was where I regenerate the login token, I delete the old one from the database. Otherwise my database was quickly flooded during testing! – Joe Coyle Jul 16 '20 at 12:35
  • I can see that $selector generated by random_bytes(9). Shouldn't it be unique? Consider two different users have same $selector, so what will be happened!? – Kranchi Apr 04 '23 at 20:56