Laravel AES-256-GCM API Payload Encryption Tutorial
Maksudur Rahman
Software Engineer
Transmitting raw JSON payloads over REST APIs exposes request data to network inspection, proxy logging, and browser extensions. While HTTPS encrypts transport layer packets between client and server, end-to-end application payload encryption ensures data remains confidential inside server logs and intermediate proxies.
In this tutorial, you will implement authenticated AES-256-GCM request payload decryption and response encryption middleware in Laravel 11, Laravel 12, paired with a Next.js client using the Web Crypto API.
sequenceDiagram
autonumber
actor Client as Next.js Client
participant Middleware as DecryptRequestMiddleware
participant Controller as Laravel Controller
participant EncMiddleware as EncryptResponseMiddleware
Client->>Middleware: POST /api/v1/resource {payload, iv, tag, ts}
Note over Middleware: Verify timestamp & decrypt payload via OpenSSL AES-256-GCM
Middleware->>Controller: Forward request with decrypted JSON input
Controller-->>EncMiddleware: Return standard JSON response envelope
Note over EncMiddleware: Encrypt response data using AES-256-GCM
EncMiddleware-->>Client: Return {payload, iv, tag, ts}Quick Summary / Prerequisites
Frameworks: Laravel 11.x (PHP 8.2+) & Next.js 14/15 (App Router)
Encryption Algorithm:
aes-256-gcm(Galois/Counter Mode with authentication tag)Required PHP Extensions:
openssl,ext-jsonSkill Level: Intermediate to Advanced
Step 1: Configuring Environment & Key Generation
AES-256 requires a 256-bit (32-byte) secret key. Store this key in your.env file encoded as base64.
Generate a secure key in your terminal:
php -r "echo 'base64:' . base64_encode(random_bytes(32)) . PHP_EOL;"Add the key and toggle flag to your Laravel.env file:
API_ENCRYPTION_ENABLED=true
API_ENCRYPTION_KEY=base64:your_generated_32_byte_base64_string_hereRegister configuration entries inside config/app.php:
'api_encryption_key' => env('API_ENCRYPTION_KEY'),
'api_encryption_enabled' => (bool) env('API_ENCRYPTION_ENABLED', false),Step 2: Creating the Laravel Encryption Service
Create a dedicated service class to handle binary OpenSSL operations. Place this in app/Services/EncryptionService.php.
<?php
namespace App\Services;
use Illuminate\Support\Facades\Log;
class EncryptionService
{
protected string $key;
protected string $cipher = 'aes-256-gcm';
public function __construct()
{
$rawKey = config('app.api_encryption_key', env('API_ENCRYPTION_KEY'));
if (str_starts_with($rawKey, 'base64:')) {
$rawKey = base64_decode(substr($rawKey, 7));
}
$this->key = $rawKey;
}
public function encrypt(mixed $data): array
{
$iv = random_bytes(openssl_cipher_iv_length($this->cipher));
$tag = '';
$payloadString = is_string($data) ? $data : json_encode($data);
$encryptedBinary = openssl_encrypt(
$payloadString,
$this->cipher,
$this->key,
OPENSSL_RAW_DATA,
$iv,
$tag
);
return [
'payload' => base64_encode($encryptedBinary),
'iv' => base64_encode($iv),
'tag' => base64_encode($tag),
'ts' => time(),
];
}
public function decrypt(string $payload, string $iv, string $tag): mixed
{
try {
$decryptedRaw = openssl_decrypt(
base64_decode($payload),
$this->cipher,
$this->key,
OPENSSL_RAW_DATA,
base64_decode($iv),
base64_decode($tag)
);
if ($decryptedRaw === false) {
return null;
}
$decodedJson = json_decode($decryptedRaw, true);
return $decodedJson ?? $decryptedRaw;
} catch (\Exception $e) {
Log::error('AES-256-GCM Decryption failure: ' . $e->getMessage());
return null;
}
}
}Step 3: Implementing Decryption & Encryption Middleware
1. Request Decryption Middleware
Create app/Http/Middleware/DecryptRequestMiddleware.php. This middleware verifies request freshness and decrypts incoming body parameters before passing control to controllers.
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use App\Services\EncryptionService;
use Symfony\Component\HttpFoundation\Response;
use Maksudur\ApiResponse\ApiResponse;
class DecryptRequestMiddleware
{
protected EncryptionService $encryptionService;
public function __construct(EncryptionService $encryptionService)
{
$this->encryptionService = $encryptionService;
}
public function handle(Request $request, Closure $next): Response
{
if (!config('app.api_encryption_enabled', false)) {
return $next($request);
}
// Bypass file uploads and specified non-encrypted header overrides
$contentType = $request->header('Content-Type', '');
if (str_contains($contentType, 'multipart/form-data') || $request->header('X-Encryption-Enabled') === 'false') {
return $next($request);
}
if (!$request->has(['payload', 'iv', 'tag', 'ts'])) {
if ($request->isMethod('GET') || $request->isMethod('DELETE')) {
return $next($request);
}
return ApiResponse::error('Missing required encrypted parameters.', null, 400, 400);
}
// Validate request window freshness (10-minute maximum age)
$timestamp = (int) $request->input('ts');
if (abs(time() - $timestamp) > 600) {
return ApiResponse::error('Encrypted request timestamp has expired.', null, 401, 401);
}
$decryptedData = $this->encryptionService->decrypt(
$request->input('payload'),
$request->input('iv'),
$request->input('tag')
);
if ($decryptedData === null) {
return ApiResponse::error('Payload authentication or decryption failed.', null, 401, 401);
}
// Overwrite request input with decrypted parameters
$dataArray = is_array($decryptedData) ? $decryptedData : [];
$request->replace($dataArray);
$request->query->add($dataArray);
return $next($request);
}
}2. Response Encryption Middleware
Create app/Http/Middleware/EncryptResponseMiddleware.php to wrap outgoing API responses.
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use App\Services\EncryptionService;
use Symfony\Component\HttpFoundation\Response;
use Illuminate\Http\JsonResponse;
class EncryptResponseMiddleware
{
protected EncryptionService $encryptionService;
public function __construct(EncryptionService $encryptionService)
{
$this->encryptionService = $encryptionService;
}
public function handle(Request $request, Closure $next): Response
{
/** @var Response $response */
$response = $next($request);
if (!config('app.api_encryption_enabled', false) || $request->header('X-Encryption-Enabled') === 'false') {
return $response;
}
if ($response instanceof JsonResponse) {
$originalContent = $response->getData(true);
$encryptedEnvelope = $this->encryptionService->encrypt($originalContent);
$response->setData($encryptedEnvelope);
}
return $response;
}
}Register both middleware classes in bootstrap/app.php:
->withMiddleware(function (Middleware $middleware): void {
$middleware->prepend(\App\Http\Middleware\EncryptResponseMiddleware::class);
$middleware->prepend(\App\Http\Middleware\DecryptRequestMiddleware::class);
})Step 4: Next.js Web Crypto API Client Integration
To interoperate with PHP's openssl_encrypt, implement encryption in Next.js using the browser-native Web Crypto API (crypto.subtle).
Create lib/utils/aes.ts:
const SECRET_KEY = process.env.NEXT_PUBLIC_API_ENCRYPTION_KEY || 'base64_key_here';
function base64ToArrayBuffer(base64: string): Uint8Array {
const binary = atob(base64.startsWith('base64:') ? base64.substring(7) : base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return bytes;
}
function arrayBufferToBase64(buffer: ArrayBuffer | Uint8Array): string {
const bytes = buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer);
let binary = '';
for (let i = 0; i < bytes.byteLength; i++) {
binary += String.fromCharCode(bytes[i]);
}
return btoa(binary);
}
async function importKey(): Promise<CryptoKey> {
const rawKey = base64ToArrayBuffer(SECRET_KEY);
return crypto.subtle.importKey(
'raw',
rawKey,
{ name: 'AES-GCM' },
false,
['encrypt', 'decrypt']
);
}
export async function encryptPayload(data: Record<string, any>) {
const key = await importKey();
const iv = crypto.getRandomValues(new Uint8Array(12)); // 96-bit IV for AES-GCM
const encodedData = new TextEncoder().encode(JSON.stringify(data));
const encryptedBuffer = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv },
key,
encodedData
);
// Split encrypted ciphertext and 16-byte authentication tag
const ciphertextLength = encryptedBuffer.byteLength - 16;
const ciphertext = encryptedBuffer.slice(0, ciphertextLength);
const tag = encryptedBuffer.slice(ciphertextLength);
return {
payload: arrayBufferToBase64(ciphertext),
iv: arrayBufferToBase64(iv),
tag: arrayBufferToBase64(tag),
ts: Math.floor(Date.now() / 1000),
};
}Step 5: Common Errors & Troubleshooting (Gotchas)
Error 1:
Decryption failed (openssl_decrypt returns false)Cause: IV length mismatch between client and server, or authentication tag truncation.
Fix: Ensure the IV length generated by Web Crypto API is exactly 12 bytes (
Uint8Array(12)). OpenSSL GCM tag must be 16 bytes.
Error 2:
Request expired (HTTP 401)Cause: Clock skew between web application host server and client machine.
Fix: Check server NTP sync or adjust timestamp window variance limit in
DecryptRequestMiddleware(abs(time() - $ts) > 600).
Error 3:
Missing required encrypted parameters (HTTP 400)Cause: Frontend sent unencrypted JSON or omitted
tagfield.Fix: Verify your HTTP interceptor wraps request data with
encryptPayload()before sending POST requests.
Pro-Tips & Performance Best Practices
Selective Bypass Header: Add an internal header
X-Encryption-Enabled: falsefor development tooling, health checks, or webhook callbacks.Replay Protection: Cache received
ts+ivsignatures in Redis for 10 minutes to reject duplicate replayed requests.Streamed Upload Exclusions: Exclude
multipart/form-datauploads from payload encryption to prevent high memory consumption during large file transfers.
Next Steps
Now that your API endpoints enforce payload encryption, add endpoint validation tests using Laravel's$this->postJson() helper or inspect request logs to verify no plain-text parameters are stored in server access logs.