In Depth: Graceful Encryption Key Rotation
[In Depth #25] Laravel makes effective use of encryption for security purposes, but what happens if your encryption key needs to be rotated? Let's see how Laravel 11 handles it...
Let’s continue our series on Laravel 11’s security features by taking an In Depth look at one of the bigger additions: Graceful Encryption Key Rotation.
Here’s what the release notes say:
Since Laravel encrypts all cookies, including your application's session cookie, essentially every request to a Laravel application relies on encryption. However, because of this, rotating your application's encryption key would log all users out of your application. In addition, decrypting data that was encrypted by the previous encryption key becomes impossible.
Laravel 11 allows you to define your application's previous encryption keys as a comma-delimited list via theAPP_PREVIOUS_KEYS
environment variable.
When encrypting values, Laravel will always use the "current" encryption key, which is within theAPP_KEY
environment variable. When decrypting values, Laravel will first try the current key. If decryption fails using the current key, Laravel will try all previous keys until one of the keys is able to decrypt the value.
This approach to graceful decryption allows users to keep using your application uninterrupted even if your encryption key is rotated.
For more information on encryption in Laravel, check out the encryption documentation.
Before we dive into how this works, and the things to watch out for, especially around encrypted model attributes, and signed URLs, let’s take a look at an example for a situation where we’d want key rotation.
What Is Key Rotation?
To demonstrate the process, I’ve created a new project with Breeze, registered an account, and have logged in. This is my dashboard, showing my session cookie:
My session cookie is:
eyJpdiI6Ijd5Yjg1Tm9nMktLQjlmdER5cWNHNkE9PSIsInZhbHVlIjoic0dzK2hCWGJCUmtZSXphVW9jZm5GMzhWRnV0ektuYnpRZURHOXNDZlpMbXo1ZW9ORkM2WXpQRVJIb0c1bHdxNUwreTcrdVNwb2tnYTk3elp0ZWdtRFFXVzhQR1QrYTFZT0wyV2xpVHB6OFRaTmVza1hTSXpxdkczVzBiZlhZdG8iLCJtYWMiOiI4NTJjNDM4NDcwYjNiMDBiZjUyYjg3NDkxODM1OTg0ZWMzYTlkZTUwNDFhYWUxNzU3MWI1NTQ3YzA2YWFhMGE4IiwidGFnIjoiIn0%3D
Throwing that into Tinker lets us decrypt the session ID (with the app’s encryption key):
> $payload = "eyJpdiI6Ijd5Yjg1Tm...FhYWU3MWI1NTQ3YzA2YWFhMGE4IiwidGFnIjoiIn0=";
> Crypt::decryptString($payload);
= "cd7923932b6929e1ba1a6b26f7b0cd8fdd2388ac|Ez48C29zTQAGyUH1xEeP9ardH9RxTuVus6xofACc"
So far this is all expected and normal.
Let’s change the encryption key1 and see what happens when we refresh the page:
As we expected to happen, we’re no longer logged in. The cookie value hasn’t changed, but it can no longer be decrypted by the app and thus we’re logged out.
Jumping back into Tinker confirms the issue:
> $payload = "eyJpdiI6Ijd5Yjg1Tm...FhYWU3MWI1NTQ3YzA2YWFhMGE4IiwidGFnIjoiIn0=";
> Crypt::decryptString($payload);
Illuminate\Contracts\Encryption\DecryptException The MAC is invalid.
Let’s add in the new APP_PREVIOUS_KEYS
environment variable with our original key back into .env
:
APP_KEY=base64:qiaiEvcimxdYssraJ4GcnFIVQtgdmSM0tolb+fGrOmo=
APP_PREVIOUS_KEYS=base64:pfFdgsZSjCIPtdo2St9nDNDR0Ml8imXG7o1oEa6I04c=
And then refresh the page…
At this point I refreshed the page, saw myself still logged out and spend 15 minutes debugging the issue before realising that Laravel had changed the cookie value! I grabbed the original value from above, pasted it into Chrome, refreshed, and I was logged back in! Oh and I won’t bother with a screenshot - it’s identical to the first one.
And we’re logged back in!
Session Retention
What we’ve just tested is session retention - ensuring that existing users who are already logged in aren’t kicked out of the app when you rotate the keys.