Laravel AES-256-GCM API Payload Encryption Tutorial

Avatar
M

Maksudur Rahman

Software Engineer

385Views
5mRead
0Reactions

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-json

  • Skill 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_here

Register 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 tag field.

    • Fix: Verify your HTTP interceptor wraps request data with encryptPayload() before sending POST requests.

Pro-Tips & Performance Best Practices

  1. Selective Bypass Header: Add an internal header X-Encryption-Enabled: false for development tooling, health checks, or webhook callbacks.

  2. Replay Protection: Cache received ts+iv signatures in Redis for 10 minutes to reject duplicate replayed requests.

  3. Streamed Upload Exclusions: Exclude multipart/form-data uploads 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.

Recommended Resources & Courses

React to this article