Resonant Core

Remember Me Safely - Secure Long-Term Authentication Strategies

Let's say you have a web application with a user authentication system, wherein users must provide a username (or email address) and password to access certain resources. Let's also say that it's properly designed (it uses password_hash() and password_verify() and rate-limiting; it doesn't have any SQli or XSS flaws).

Everything is going well for a while, but eventually your users would like the convenience of a "Remember me on this computer" button. What do you do?

Naive Solution: Just Store User Credentials in a Cookie

A common first thought is to just set a cookie to the user's ID number and call it a day. This is problematic because cookies are entirely under the user's control. If you rely on userid=1301 to automatically log user "John" in without having to re-enter their credentials, you've created a trivially exploitable backdoor that could have disastrous results. Furthemore, the administrators of a website typically have lower ID numbers, which means userid=1 is almost always going to guarantee privileged application access.)

This may seem obvious when it's stated, but the author has found this flaw in many niche websites and HTML5-based Android apps. In one such app (a social one, where users were located by username), they stored the user's username and password in plaintext cookies and neglected to mark them as HTTP-Only. To make matters worse, their platform was also susceptible to cross-site scripting attacks. Suffice to say, the potential for harm was significant.

Don't store your user's credentials in a cookie to solve this problem. It's a recipe for disaster.

Tokens and Look-Up Tables

Another common strategy, much less susceptible to attack, is to just generate a unique token when a user checks the "Remember Me" box, store the unique token in a cookie, and have a database table that associates tokens with each user's account. There are a number of things that could still go wrong here, but it is unquestionably an improvement over the previous strategy.

Problem 1: Insufficient Randomness

Although many developers understand the need for unpredictability in security tokens, many do not know how to actually achieve this goal. A not-too-uncommon code snippet for generating unique tokens looks something like this.

function generateToken($length = 20)
{
    $buf = '';
    for ($i = 0; $i < $length; ++$i) {
        $buf .= chr(mt_rand(0, 255));
    }
    return bin2hex($buf);
}

The mt_rand() function is not suitable for security purposes. If you need to generate a random number in PHP, you want one of the following:

Problem 2: Timing Leaks

Let's say you're using a cryptographically secure random number generator, but your cookie looks like rememberme=oMFxsiGRGQNJ8ZRjZtU0Mn6n8gtN3gOuQ2wjNWBWgm2 and you're storing these tokens in a database table that looks like this:

CREATE TABLE `auth_tokens` (
    `id` integer(11) not null UNSIGNED AUTO_INCREMENT,
    `token` char(33),
    `userid` integer(11) not null UNSIGNED,
    `expires` integer(11), -- or datetime
    PRIMARY KEY (`id`)
);

Your lookup query is, therefore, probably similar to (but accomplished through parametrized queries because we don't like unnecessary risks):

SELECT * FROM auth_tokens WHERE token = 'oMFxsiGRGQNJ8ZRjZtU0Mn6n8gtN3gOuQ2wjNWBWgm2';

Watch out, an esoteric and nontrivial attack still exists.

This may seem fine at first glance, but this actually leaks timing information due to the way strings are compared in database operations.

To clarify: if we change first byte in our rememberme cookie from an o to a p the comparison will fail slightly faster than if we incremented the last character from 2 to 3. Google's Anthony Ferrara covered this topic in his blog post, It's All About Time.

On modern hardware, this timing difference is only significant at the nanosecond scale. This is not a simple or easy attack to pull off, but why build a security system if you're going to take unnecessary risks?

Sidenote: This behavior is, by all accounts, not a deficit of any particular database server. Searching a dataset is not the sort of operation you want to be constant-time. Doing so would open the door to denial-of-service attacks.

To make matters worse, if the query doesn't find a valid entry for the supplied remember me token, your attacker get unlimited tries. Especially if your application is not tracking and rate-limiting auto-logins.

To make sure our "remember me" tokens are iron-clad, let's abstract the lookup from the verification and make sure we do so in constant-time. hash_equals() is useful here!

How Resonant Core Would Design Such a System

Now that we've covered some of the pitfalls of common (and often exploitable) "remember me" cookie strategies, let's look at how to build it right. There are some more ways this feature can go wrong, but for the sake of balance we're going to focus on a solid implementation.

First and foremost, we're going to use parametrized queries exclusively throughout our entire application. You should already be doing this. Our DB class was designed specifically for this purpose.

As outlined above, we're going to separate the auto-login tokens into its own table.

We will generate a random token and store id:token in a cookie.

Instead of just storing the authenticator in the database, we're going to hash the token, so that if somehow the auth_tokens table is leaked, it does not immediately open the door for widespread user impersonation. To prevent side-channels, we use hash_equals() (available in PHP 5.6) to compare the stored hash with the hash of the token the user provides.

Our database table is going to look something like this:

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`)
);

While our code might look something like:

public function rememberMe($userid)
{
    $token = \Resonantcore\Lib\Secure::random_bytes(24);
    
    // Guarantee a unique selector in the event of a birthday collision (2^-36)
    // The DBRO in $this->dbro means DataBaseReadOnly
    do {
        $selector = \Resonantcore\Lib\Secure::random_bytes(9);
    } while($this->dbro->cell("SELECT count(id) FROM auth_tokens WHERE selector = ?", $selector) > 0);
    
    $expires = new \DateTime('now');
    $expires->add(new \DateTimeInterval('P14D'));
    
    $this->db->insert('auth_tokens', [
         'expires' => $expires->format('Y-m-d H:i:s'),
         'selector' => $sel,
         'userid' => $userid,
         'token' => \hash('sha256', $token)
    ]);
    \setcookie(
        'auth',
        \base64_encode($selector).':'.\base64_encode($token),
        time() + 1209600,
        '/',
        '.example.com',
        true,
        true
    );
}

/**
 * Only invoke this method when $_SESSION['current_user'] is not set.
 */
public function autoLogin()
{
    if (!empty($_COOKIE['auth']))) {
        $split = explode(':', $_COOKIE['auth']);
        if (count($split) !== 2) {
            $this->logger->warn("Badly formed auth cookie!");
            return false;
        }
        list($selector, $token) = $split;
        $dbresult = $this->dbro->row("
                SELECT
                    id, token, userid 
                FROM auth_tokens
                WHERE 
                    selector = ? AND expires <= CURDATE()
            ", 
            $selector
        );
        if ($dbresult) {
            if (\hash_equals(
                $dbresult['token'],
                \hash('sha256', \base64_decode($token))
            )) {
                // Privilege escalation - get a new random session ID
                session_regenerate_id(true);
                
                // Let's remove our old token.
                $this->db->delete('auth_tokens', [
                    'id' => $dbresult['id']
                ]);
                
                // Let's set the session variable appropriately...
                $_SESSION['current_user'] = $dbresult['userid'];
                
                // Generate a new token for future convenience...
                $this->rememberMe($dbresult['userid']);
                
                // We return true here.
                return true;
            }
        }
        $this->logger->warn("Invalid auth cookie!");
    }
    // Default case: It was unsuccessful somehow...
    return false;
}

As you clearly can see, we leave nothing to chance when building applications for ourselves and our clients. If you would like us to manage the security complexity of your next project, shoot us an email with what your goals and concerns are.

Update (2015-02-03)

Taylor Hornby and Anthony Ferrara provided valuable feedback to this article. In addition to other changes, he advised against using the password_* API on anything except a user-provided password, since high-entropy values do not require key-stretching. An earlier version of this article used password_hash() and password_verify().

Scott Arciszewski

Chief Development Officer

With over 12 years of software development and system administration experience, Scott aspires to, by solving difficult problems and automating trivial tasks, help others attain a happier work-life balance.