coding

Sharing encrypted Laravel cookies with Next.js

Bridging Laravel and Next.js: How we synchronized encrypted cookies between two different frameworks.

Written in October 25, 2024 - 🕒 9 min. read

I often have friends asking me, “Pablo, how do you manage to stay so calm and collected in the face of complex technical challenges?” Well, the truth is, I don’t. I just write blog posts about them to make it seem like I do. This is one of those stories.

I recently found myself needing to crack open the values of cookies coming from a Laravel application, read them, update them, and then patch them back up in a way the Laravel application wouldn’t even notice. Sounds like the plot of a Mission Impossible movie, right? Let’s roll with that, and I’m Tom Cruise.

The infamous Next.js migration

After migrating from CoffeeScript to ES6 and then from ES6 to TypeScript, it was time for another major refactor: detaching our frontend from Laravel into a separate Next.js app.

In order to improve performance, developer experience, and employer desirability (shameless plug, we’re hiring at Studocu wink-wink), we needed a robust frontend framework that could handle our growing user base and feature set. Next.js seemed like the perfect fit.

This theoretically would help us stop reinventing the wheel - like writing endless lines of Webpack configurations - and focus on what really matters: building features that our users love. So the plan is that instead of having one spaghetti codebase, we would have two smaller, noodle-like codebases.

Spaghetti code
Spaghetti code

But why?

As one-time Grammy-nominated Ryan Reynolds once said: But why?

Actual footage of readers of this post

Good question! Here’s the thing: we decided that the best way to implement this transition to Next.js was to move one page at a time. This means we would have to share cookies between the Laravel and Next.js applications. While this brings lots of benefits, it also presents some unique challenges.

The biggest challenge is that Laravel and Next.js don’t exactly speak the same language when it comes to cookie encryption. Laravel uses its own encryption algorithm and, by default, encrypts every cookie using a private key. Without this key, and without reverse-engineering Laravel’s algorithm, it’s impossible to read the data on the Next.js side.

On the other hand, Next.js is quite unopinionated about how you store your cookies. Since we were spinning up a new Next.js application from scratch, this was a perfect opportunity to align it with Laravel’s encryption method, allowing us to freely decrypt and encrypt Laravel’s cookies directly in Next.js without risking breaking anything.

Before we could solve this problem, we needed to understand how Laravel encrypts its cookies. Turns out, there isn’t a whole lot of information out there about this. In an age where it’s hard to find something unique to write a blog post about, I thought this would be the perfect opportunity to share some insights.

So, how do we figure out how Laravel encrypts its cookies? By diving into the source code, of course! Fortunately, Laravel’s source code is quite readable and well-organized, so it didn’t take long to track down the relevant parts.

Application Key

Laravel uses a private key, known as the Application Key, for encryption, which can be generated using the php artisan key:generate command. It employs a AES-256-CBC cipher for encryption, which requires an Initialization Vector (IV). Additionally, Laravel uses a Message Authentication Code (MAC) to ensure the integrity of the encrypted data.

Initialization Vector

The IV is a random value that’s used to ensure that the same plaintext doesn’t encrypt to the same ciphertext. Laravel generates a random IV for each cookie and stores it alongside the encrypted value.

Message Authentication Code

The MAC is a cryptographic checksum that ensures the integrity of the encrypted data. Laravel generates a MAC using the IV and the encrypted value, which is then used to verify the integrity of the data during decryption.

Flow chart

Encryption flow
Encryption flow

All of this might change depending on your version of Laravel or how much you’ve customized your encryption settings. You can check your config/app.php file to see how your encryption settings are configured.

Here’s a simplified breakdown of the process:

  1. Cookie Value: The cookie value is stringified and then prefixed with a SHA-1 hash based on the cookie name and a version string ("v2").
  2. Encryption: The prefixed value is encrypted using AES-256-CBC.
  3. MAC Generation: A MAC is generated using HMAC-SHA256 over the IV and the encrypted value.
  4. Payload Assembly: The encrypted value, IV, MAC, and a tag are assembled into a JSON object.
  5. Base64 Encoding: This JSON object is then base64-encoded to form the final cookie value.

An example of the encrypted cookie structure looks like this:

{
  "value": "encryptedValue",
  "iv": "base64-encoded-IV",
  "mac": "hex-encoded-MAC",
  "tag": "base64-encoded-tag"
}

Understanding this structure was crucial for replicating the encryption and decryption processes in Next.js.

Recreating Laravel’s encryption in Next.js

To read and update Laravel’s encrypted cookies in Next.js, we needed to replicate Laravel’s encryption logic in our Next.js application. Here’s how we tackled it.

Same cookies
Same cookies

Decrypting cookies

We created a function decryptCookieValue that mirrors Laravel’s decryption process. Here’s a simplified version:

export const decryptCookieValue = async (encryptedCookieValue) => {
  const publicKey = process.env.APP_KEY;
  const encoder = new TextEncoder();
  const decoder = new TextDecoder();

  // Decode and parse the cookie
  const decodedResult = Buffer.from(encryptedCookieValue, 'base64').toString('utf-8');
  const parsedResult = JSON.parse(decodedResult);

  const { iv, mac, value } = parsedResult;

  // Verify the MAC
  const macPayload = iv + value;
  const keyMaterial = encoder.encode(publicKey);
  const hmacKey = await crypto.subtle.importKey('raw', keyMaterial, { name: 'HMAC', hash: 'SHA-256' }, false, ['verify']);
  const isMacValid = await crypto.subtle.verify('HMAC', hmacKey, Buffer.from(mac, 'hex'), encoder.encode(macPayload));

  if (!isMacValid) {
    throw new Error('MAC validation failed');
  }

  // Decrypt the value
  const ivArray = Buffer.from(iv, 'base64');
  const encryptedData = Buffer.from(value, 'base64');
  const cryptoKey = await crypto.subtle.importKey('raw', keyMaterial, { name: 'AES-CBC' }, false, ['decrypt']);
  const decryptedData = await crypto.subtle.decrypt({ name: 'AES-CBC', iv: ivArray }, cryptoKey, encryptedData);

  const decryptedString = decoder.decode(decryptedData);

  // Remove Laravel's cookie prefix
  return decryptedString.slice(41);
};

This function decrypts the cookie value, verifies the MAC, and returns the decrypted string. We also had to remove Laravel’s cookie prefix with a slice - pretty much how it’s done in the original code - to get the original cookie value.

Encrypting cookies

We also needed to encrypt cookies in Next.js so that Laravel could understand. Here’s the encryptCookieValue function:

export const encryptCookieValue = async (cookieName, value) => {
  const publicKey = process.env.APP_KEY;
  const encoder = new TextEncoder();

  // Create the Laravel cookie prefix
  const cookiePrefix = await createLaravelCookiePrefix(cookieName);
  const prefixedValue = cookiePrefix + value;

  // Generate a random IV
  const iv = crypto.getRandomValues(new Uint8Array(16));
  const keyMaterial = encoder.encode(publicKey);

  // Encrypt the value
  const cryptoKey = await crypto.subtle.importKey('raw', keyMaterial, { name: 'AES-CBC' }, false, ['encrypt']);
  const encryptedData = await crypto.subtle.encrypt({ name: 'AES-CBC', iv }, cryptoKey, encoder.encode(prefixedValue));

  // Generate the MAC
  const encryptedBase64 = Buffer.from(encryptedData).toString('base64');
  const ivBase64 = Buffer.from(iv).toString('base64');
  const macPayload = ivBase64 + encryptedBase64;
  const hmacKey = await crypto.subtle.importKey('raw', keyMaterial, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
  const macData = await crypto.subtle.sign('HMAC', hmacKey, encoder.encode(macPayload));
  const macHex = Buffer.from(macData).toString('hex');

  // Assemble the payload
  return Buffer.from(
    JSON.stringify({
      iv: ivBase64,
      mac: macHex,
      tag: '',
      value: encryptedBase64,
    })
  ).toString('base64');
};

This function encrypts the cookie value, generates a MAC, and assembles the encrypted cookie payload.

And here’s the helper function to create the Laravel cookie prefix:

const createLaravelCookiePrefix = async (cookieName) => {
  const publicKey = process.env.APP_KEY;
  const encoder = new TextEncoder();
  const data = encoder.encode(`${cookieName}v2`);
  const key = encoder.encode(publicKey);

  const cryptoKey = await crypto.subtle.importKey('raw', key, { name: 'HMAC', hash: 'SHA-1' }, false, ['sign']);
  const signature = await crypto.subtle.sign('HMAC', cryptoKey, data);
  const hexSignature = Buffer.from(signature).toString('hex');

  return `${hexSignature}|`;
};

These functions ensure that cookies encrypted in Next.js can be decrypted by Laravel, allowing for seamless cross-framework integration.

Challenges and security considerations

Of course, this process wasn’t without its hurdles:

  • Cryptographic compatibility: Matching Laravel’s encryption in Node.js requires careful handling of encoding, data types, and cryptographic functions.
  • Environment constraints: We had to ensure that these functions only run on the server side, as they rely on server-side environment variables and cryptography APIs.
  • Security considerations: We took extra care to ensure that the APP_KEY remains secure and never exposed to the client side. Error handling, MAC verification, and ensuring the IV is unique for each encryption were key to preventing security vulnerabilities.
  • Testing and debugging: Verifying that our encryption and decryption matched Laravel’s required a lot of trial and error, and a deep understanding of both frameworks.
  • Deciding which cookies to encrypt: Not all cookies needed encryption, so we carefully selected only the ones that handle sensitive data.

It’s also worth noting that this solution is specific to our use case and may not be suitable for all scenarios. It’s essential to understand the security implications of replicating encryption mechanisms and ensure that your implementation is secure and compliant with best practices.

The payoff: A seamless user experience

Fully integrated
Fully integrated

By replicating Laravel’s encryption mechanisms in Next.js, we achieved a seamless user experience. Users can navigate between Laravel and Next.js pages without any hiccups, and their session data remains consistent throughout. Honestly, it’s quite impressive how seamlessly the whole flow works without users even noticing the transition between the two frameworks.

This solution also opens up new possibilities for integrating other platforms with Laravel, as long as they can replicate Laravel’s encryption process. It’s a testament to the power of understanding and adapting complex systems to achieve interoperability.

Conclusion

That's the way the cookie crumbles

Integrating cookies between two different frameworks and programming languages isn’t exactly a walk in the park - unless the park is Central Park, and you’re John Wick right after being excommunicado. But with a deep dive into how each framework handles encryption and a fair bit of perseverance (and maybe some mate), it’s definitely doable.

I hope this explanation sheds some light on how to tackle such a challenge. If you find this kind of problem-solving exciting, maybe you’d like to join us. We’re always on the lookout for talented individuals - check out our job openings. I know I might have made it sound like a nightmare, but trust me, it’s the fun kind!

Happy coding!

Tags:


Post a comment

Comments

No comments yet.