The Comprehensive Guide to URL Parameter Encryption in PHP

The question of how to encrypt/decrypt a URL parameter (such as a username, email address, or primary key for a relational database) has become increasingly common, so we'd like to offer a simple and actionable solution for people who are asking the same (or a very similar) question.

Don't.

Cryptography is a tricky field, especially for newcomers. To a security expert, it's immediately obvious why encrypting URL parameters is a bad idea. Let me explain why, and then I'll offer a superior alternative solution.

Why Encrypting URL Parameters is a Bad Idea

Typically, the desired result of encrypting a URL looks like this:

One problem arises that, given the desired outcome of a very short URL (which is a common constraint to any system that sends URLs over SMS), there isn't enough room to both encrypt the desired information and then authenticate the encrypted output. Encryption without message authentication is totally broken.

Unless you're a cryptographer or security engineer, you wouldn't know these details. In this situation, encryption adds complexity and lots of room for nefarious errors to your application, for no real benefit. Obfuscation is a trivial task, as we'll demonstrate below; why add unnecessary complexity if you can avoid it?

What About Hashids?

From the What Not To Do section of the Hashids page:

Do not encode sensitive data. This includes sensitive integers, like numeric passwords or PIN numbers. This is not a true encryption algorithm.

The hashids protocol has been publicly broken by simple cryptanalysis techniques.

This might seem like an attractive solution, but it won't stop users from trivially teasing the underlying database row ID out of your obfuscated URL parameter. Hashids are not secure; don't use them.

What Should I Do Instead?

Okay, by this point, we hope you're convinced that neither encryption nor hashids are the way to go forward. Encryption is very hard to get right, and hashids are not secure.

But knowing this doesn't solve your problem: How can you serve content via an obfuscated URL without resorting to encryption?

The answer is: add another column to the table with a unique, random token and reference that in your database lookups instead of your database identifier.

A little bit of math:

In MySQL, an INTEGER(11) UNSIGNED primary key can hold about 4 billion rows. This is equal to 32 bits.

primary key can hold about 4 billion rows. This is equal to 32 bits. If you generate 9 raw bytes from a cryptographically secure pseudorandom number generator (72 bits of possible values), then base64 the result, you will end up with a 12 character identifier.

72 bits of possible values means a 50% chance of collision at $2^{36}$ records, according to the birthday problem.

This means you have a 50% chance of only two duplicate random values after about 69 billion records (a far cry beyond your 4 billion storage capacity). This means that you will almost never have a collision. You should still make sure every selector you generate is unique before inserting a new one, of course.

Furthermore, this record is completely randomly generated and has nothing to do with the rest of the data stored in your database. There is no pattern to be found. (The closest relevant buzz-word here is a "zero knowledge".)

Instead of encrypting URL parameters, add a column that stores a random 12-character string for each row (which is generated by base64-encoding 9 bytes from a CSPRNG), and use that in your URLs and SELECT queries.

use ParagonIE\ConstantTime\Base64UrlSafe; /** * Generate a selector * * @return string (12 characters) */ function generateSelector(): string { return Base64UrlSafe::encode(random_bytes(9)); /* Equivalent: return strtr(base64_encode(random_bytes(9)), '+/', '-_'); */ }

(Code snippet above uses paragonie/constant_time_encoding.)

This problem is simpler and less likely to cause bugs or implementation errors now and going forward.

A Quick Note about Access Controls

An obfuscated URL does not obviate the need for access controls. The use case here is, "I want to serve a unique ID for a particular resource without leaking metadata about the activity level of our app."

Do not use obfuscated URLs as a backdoor into your application.

But What if I Really Want to Encrypt URL Parameters?

If after seeing our above recommendation you still want to go forward with encrypting your GET parameters, don't write your own cryptography. Use a peer-reviewed, secure PHP cryptography library instead.

Update: "How about timing attacks when selecting the token that way?"

This question was posed by Niklas Keller after the initial publication of this blog post. We feel that it's interesting enough to answer here in detail.

Unless you know otherwise, you should generally assume that database lookups are vulnerable to timing attacks. You can design this URL obfuscation scheme in a timing-attack-resistant way, but it will result in a longer URL parameter and a little bit more application complexity. Be forewarned that fixed/random delays are not a solution for timing attacks.

The solution here is to employ split tokens (which is very similar to our secure implementation of "remember me" cookies).

Instead of adding one column ( selector above), we're going to add two: selector and verifier . Our URLs will consist of the two concatenated together.

When we perform a lookup, we'll grab the first $N$ (let's say 12) characters and feed it into the selector. If a record is found, we will pull the row then compare the remainder of our URL parameter with verifier using PHP's hash_equals() function. If hash_equals() doesn't return true , act like the row was not found.

Even if an attacker can use a timing side-channel on the first 12 characters of a URL, if the remaining $M$ characters (let's say 24) are not vulnerable to timing attacks.

use ParagonIE\ConstantTime\Base64UrlSafe; /** * Generate a selector * * @param bool $skipUniqueCheck * @return string (12 characters) */ function generateSelector(bool $skipUniqueCheck = false): string { /** @var \PDO $db */ global $db; if ($skipUniqueCheck) { $selector = Base64UrlSafe::encode(random_bytes(9)); } else { $iterations = 0; do { if (++$iterations > 128) { throw new RuntimeException('Unexpected high number of selector collisions.'); } $selector = Base64UrlSafe::encode(random_bytes(9)); $exists = $db->prepare("SELECT count(*) FROM users WHERE selector = ?"); $exists->execute([$selector]); } while ($exists->fetchColumn() > 0); } return $selector; } /** * Generate a validator * * @return string (24 characters) */ function generateValidator(): string { return Base64UrlSafe::encode(random_bytes(18)); } /** * Validate a URL input * * @param string $selector (string, 36 chars) * @return array * @throws Exception */ function getUserBySelector(string $selector): array { /** @var \PDO $db */ global $db; if (preg_match('/^[0-9A-Za-z\-_]{36}$/', $selector)) { $stmt = $db->prepare("SELECT * FROM users WHERE selector = ?"); $stmt->execute([substr($selector, 0, 12)]) $user = $stmt->fetch(PDO::FETCH_ASSOC); if (!empty($user)) { if (hash_equals(substr($selector, 12), $user['validator']))) { return $user; } } } throw new Exception('Invalid URL'); }

Since this information isn't as sensitive as a long-term authentication token, we don't really need to hash the verifier in the database (but there's no harm in doing so, provided you don't need to retrieve the full URL at will).

Conclusion

Don't use encryption to obfuscate URL parameters. Use a random look-up instead.