Securing tokens in PHP

Any application aimed at presenting users with a premium, seamless UX must take account of the times when user authentication fails. What happens when a user forgets their password? What can we do to confirm sensitive operations by means of email or other out-of-band communication? How can we make an application easy to use while also keeping it secure? One mechanism, that protects both password reset links and other “secure actions” taken by way of an out-of-band confirmation is that of secure tokens.

The strongest applications protect their users’ passwords. Most commonly, this requires an application to hash users’ passwords using a one-way cryptographic function.

Password hashing allows for an application to verify a user’s identity without storing the password in plaintext. The hash is stored and can be recreated at will given knowledge of the password itself. In modern PHP, this leverages two simple functions: password_hash() and password_verify().

A password passed into password_hash() is mixed with a random salt and hashed with a cryptograpically-strong algorithm. Rather than storing the password, applications store the resulting hash. When a user later authenticates, the application retrieves the hash and passes it with the user-provided password into password_verify() for checking.

Password Reset Tokens

Presenting a user with the ability to reset their account is a solid user experience feature. It helps protect against a forgotten password or some other lost credential and makes the application friendlier at the same time. There are, however, two significant risks with presenting such a UX feature:

  1. Allowing your application to email a reset link renders the system only as secure as the user’s email account. If they use a strong password on your site, but a weak password on their email, an attacker could target their email account and potentially break their way in that way instead.
  2. Whereas a user would typically need both their username and password, reset links present a single value that allows users to take action on the server as if they’d been authenticated.

For the moment, assume your users leverage a strong password management service like 1Password for both your application and their email – we’re not going to worry about situation #1 above. Instead, we’ll focus on making a random reset token as secure as a username and password can be.

Potential Risks

User authentication requires two factors:

  • Something you are (your username or ID)
  • Something you know (your password)

Assume your reset token is generated by grabbing 16 random bytes and presenting them to the user as a hexadecimal string. The generation code would look something like:

function create_token(PDO $db, string $email): string
{
    $token = random_bytes(16);
    $token_string = bin2hex($token);

    $statement = $db->prepare('INSERT INTO reset_tokens VALUES (?, ?)');
    $statement->execute($email, $token_string);
    
    return sprintf('https://yoursite.com/reset?token=%s', $token_string);
}

The password reset link you’ve sent, however, might look something like:

https://yoursite.com/reset?token=12345678901234567890123456789012

It only has one piece of information – the token – and since everything needs to be in the same email address, you can’t easily split this into something you are and something you know. More frighteningly, this pattern potentially exposes your site to a timing attack as well!

An attacker can potentially spam your API guessing for reset tokens, tracking the relative response time of each. Due to the way SQL queries work on the data layer, an attacker could use a simply-built system to extract a reset token one character at a time.

I use “potentially” and “could” here often as a bit of a hedge. In reality, leveraging a timing attack is resource intensive and, assuming you’re properly logging your application, the team will likely detect this attack before it’s gone too far. That being said, I have both discovered and proven exploits for timing attacks of this very nature in the wild. It’s a very real risk!

There are two things your application can do right now to help protect users as they reset their accounts:

  1. Require them to confirm their email address on the reset screen. Yes, their email address is implied by and associated with the token when it’s created, but an attacker trying to “guess” a reset token won’t necessarily know which account he’s guessing for. Additionally, logging which accounts are attempting a reset (both when tokens are created and when an attacker attempts to use them) can help your team track and prevent abuse.
  2. Split the token into two components – let’s go over this a bit more in detail.

Splitting the Token

Remember, typical user authentication uses two components:

  • A username (or email address or ID) used to look up the user account in the database
  • A password used to verify the authenticating individual is the user in question

We can split our authentication token into two components and store them separately. This “split token” approach helps us protect our users against the timing based brute force attack briefly mentioned above.

An attacker will still be able to brute force the “lookup” component of the token, but they will not be able to leverage timing to extract the “verifier” piece.

Instead of storing the entire token in plain text, we’ll store the first half as a “lookup” value and store a hash of the second half. This hash – called a “verifier” – will be how the application confirms the accuracy of a reset token just like the application would confirm a user’s password.

function create_token(PDO $db, string $email): string
{
    $token = random_bytes(16);
    $token_string = bin2hex($token);
    
    $lookup = substr($token_string, 0, 16);
    $verifier = substr($token_string, 16);

    $statement = $db->prepare('INSERT INTO reset_tokens VALUES (?, ?, ?)');
    $statement->execute([$email, $lookup, password_hash($verifier, PASSWORD_DEFAULT)]);
    
    return sprintf('https://yoursite.com/reset?token=%s', $token_string);
}

The application does not keep track of the entire reset token, just the first component and a hash of the second component. When a user clicks on the link, the token can be verified just like a user’s password would be:

function verify_token(PDO $db, string $token): ?string
{
    $lookup = substr($token, 0, 16);
    $verifier = substr($token, 16);
    
    $statement = $db->prepare('SELECT * FROM reset_tokens WHERE lookup=?');
    $statement->execute([$lookup]);
    
    $result = $statement->fetchObject();
    
    if ($result && password_verify($verifier, $result->verifier) {
        return $result->email;
    }
    
    return null;
}

If the token exists, and the second half of the token hashes to the correct value, the verify_token() function returns the associated user’s email address (which can be further confirmed with the user). If the token does not exist, or if the hash verification fails, nothing happens.

Action Confirmation and Other Tokens

Password resets aren’t the only user action that takes place by way of links in an email. Consider applications like Slack that present users with “magic links” for authentication without requring a password. These links embed tokens that need to be kept secure and verified much in the same way as described above. Other sensitive actions within your application might benefit from an out-of-band confirmation of user intent.

Want to delete a user? Go to your email and click a link to confirm. Want to transfer 100BTC to a friend on Twitter? Click a link to confirm so we know you weren’t tricked. Logging in from a new device/IP address/geolocation? Check your email for a confirmation link so we know you’re really on holiday and not a victim of a hack.

Any time your application encourages user action by way of a token-bearing link, you should use a secure token that’s immune to timing attacks. It’s the best way to protect your users from a sophisticated hack.

#