Livewire Version: 3.5.12
I’d like to share and discuss an approach for adding end-to-end payload encryption on top of HTTPS for a Laravel 11 application using Livewire v3. This setup adds an additional layer of security by encrypting specific client-server payloads, keeping sensitive data secure even within HTTPS-protected requests.
Key Features:
Custom JavaScript for Client-Side Encryption: A javascript module(class) that encrypts outgoing requests and decrypts incoming responses for specified endpoints using Web Crypto API.
Middleware for Server-Side Decryption/Encryption: Laravel middleware decrypts incoming payloads and encrypts outgoing responses, providing seamless integration with the backend.
Configurable Encryption: An environment variable toggles encryption on or off, making it adaptable to different environments or deployments.
Key Exchange Mechanism: On the first load, the application fetches a public encryption key from the server, used by the custom javascript module to handle encryption.
Path Whitelisting: Certain paths can bypass encryption to avoid unnecessary processing on endpoints that don’t require this extra layer of security.
The only thing I'm struggling with is how to tap into the Livewire requests and responses to change the outgoing payload to encrypted one and change the incoming response to decrypted one. Where should I wire up the frontend code? I have looked into the Livewire docs and have tried customizing the Request Hook
Relevant snippet of the custom javascript module:
// Initialize encryption service and register Livewire hooks
document.addEventListener('DOMContentLoaded', async () => {
// Create a global instance of EncryptionService
window.encryptionService = new EncryptionService();
// Wait for Filament/Livewire to be ready
await window.$wire?.initialRender;
// Initialize encryption service
await window.encryptionService.initialize();
// Register Livewire request hook with encryption and decryption
Livewire.hook('request', async ({ uri, options, payload, respond, succeed, fail }) => {
// Encrypt request payload if encryption is enabled
if (window.encryptionService.isEnabled && window.encryptionService.sessionId && payload) {
try {
// Set headers to identify the encrypted request
options.headers = options.headers || {};
options.headers['X-Session-Id'] = window.encryptionService.sessionId;
options.headers['X-Encrypted'] = '1';
// Encrypt the payload
const encryptedPayload = await window.encryptionService.encryptPayload(payload);
console.log('Encrypted Payload:', encryptedPayload);
// Overwrite the payload with the encrypted content
payload = encryptedPayload;
} catch (error) {
console.error('Encryption failed:', error);
// If encryption fails, proceed without encryption
payload = JSON.stringify(payload);
}
}
// Handle the response
respond(({ status, response }) => {
// Check if the response is encrypted by looking at the header
const isEncrypted = response.headers.get('X-Encrypted') === '1';
// If encrypted, proceed to decrypt after parsing the raw response
if (isEncrypted && window.encryptionService.sessionId) {
response.text().then(async (encryptedContent) => {
try {
const decryptedContent = await window.encryptionService.decryptPayload(encryptedContent);
succeed({ status, json: decryptedContent });
} catch (error) {
console.error('Decryption failed:', error);
fail({ status, content: 'Decryption failed' });
}
}).catch((error) => {
console.error('Error reading response content:', error);
fail({ status, content: 'Failed to read encrypted response content' });
});
} else {
// If the response isn't encrypted, handle it as usual
succeed({ status, json: response.json() });
}
});
// If decryption fails or if there’s an error, handle it in `fail`
fail(({ status, content, preventDefault }) => {
console.error('Request failed:', status, content);
if (window.$wireui) {
window.$wireui.notification({
title: 'Error',
description: 'Failed to process request',
icon: 'error'
});
}
});
// Return the (possibly modified) payload to be sent in the request
return payload;
});
});
But this doesn't work and still sends the normal JSON payload to backend even though the above javascript is properly executed. Any pointers in right direction would be helpful
I hope this is not too late.
I think the problem lies in the object destruct you are doing because when you do that, the property is detached from the object so doing payload = encryptedPayload
is not going to do anything so you want to do request.payload = encryptedPayload
instead to make sure the object passed to the callback is mutated.
// Initialize encryption service and register Livewire hooks
document.addEventListener('DOMContentLoaded', async () => {
// Create a global instance of EncryptionService
window.encryptionService = new EncryptionService();
// Wait for Filament/Livewire to be ready
await window.$wire?.initialRender;
// Initialize encryption service
await window.encryptionService.initialize();
// Register Livewire request hook with encryption and decryption
Livewire.hook('request', async (request) => {
// Encrypt request payload if encryption is enabled
if (window.encryptionService.isEnabled && window.encryptionService.sessionId && request.payload) {
try {
// Set headers to identify the encrypted request
request.options.headers = request.options.headers || {};
request.options.headers['X-Session-Id'] = window.encryptionService.sessionId;
request.options.headers['X-Encrypted'] = '1';
// Encrypt the payload
const encryptedPayload = await window.encryptionService.encryptPayload(request.payload);
console.log('Encrypted Payload:', encryptedPayload);
// Overwrite the payload with the encrypted content
request.payload = encryptedPayload;
} catch (error) {
console.error('Encryption failed:', error);
request.payload = JSON.stringify(request.payload);
}
}
// Handle the response
request.respond(({ status, response }) => {
// Check if the response is encrypted by looking at the header
const isEncrypted = response.headers.get('X-Encrypted') === '1';
// If encrypted, proceed to decrypt after parsing the raw response
if (isEncrypted && window.encryptionService.sessionId) {
response.text().then(async (encryptedContent) => {
try {
const decryptedContent = await window.encryptionService.decryptPayload(encryptedContent);
request.succeed({ status, json: decryptedContent });
} catch (error) {
console.error('Decryption failed:', error);
request.fail({ status, content: 'Decryption failed' });
}
}).catch((error) => {
console.error('Error reading response content:', error);
request.fail({ status, content: 'Failed to read encrypted response content' });
});
} else {
// If the response isn't encrypted, handle it as usual
request.succeed({ status, json: response.json() });
}
});
// If decryption fails or if there’s an error, handle it in `fail`
request.fail(({ status, content, preventDefault }) => {
console.error('Request failed:', status, content);
if (window.$wireui) {
window.$wireui.notification({
title: 'Error',
description: 'Failed to process request',
icon: 'error'
});
}
});
});
});