Copied!
Programming
Laravel
PHP

Laravel Secure & Encrypted Queues – GDPR, Payload Inspection & Job Tampering

Laravel Secure & Encrypted Queues – GDPR, Payload Inspection & Job Tampering
Shahroz Javed
Apr 02, 2026 . 45 views

What's Actually in Your Queue Payload

Before discussing encryption, you need to understand exactly what's sitting in your queue right now. Most developers assume queue payloads are opaque. They are not — without encryption, everything in your job constructor is readable by anyone with access to your Redis instance or jobs table.

Dispatch any job without ShouldBeEncrypted and inspect it directly in Redis:

# Dispatch a job and immediately inspect it in Redis
redis-cli LRANGE queues:default 0 0

# Output (pretty-printed):
{
    "uuid": "d4c8b2a1-...",
    "displayName": "App\\Jobs\\ProcessPaymentJob",
    "job": "Illuminate\\Queue\\CallQueuedHandler@call",
    "maxTries": 3,
    "maxExceptions": null,
    "failOnTimeout": false,
    "backoff": null,
    "timeout": 60,
    "retryUntil": null,
    "data": {
        "commandName": "App\\Jobs\\ProcessPaymentJob",
        "command": "O:30:\"App\\Jobs\\ProcessPaymentJob\":4:{s:9:\"paymentId\";i:1042;s:10:\"customerId\";i:5891;s:5:\"email\";s:19:\"jane@example.com\";s:6:\"amount\";d:149.99;}"
    }
}

# The "command" field is a PHP serialized string.
# It contains ALL public and private constructor properties in plaintext.
# Email address, payment amount, customer ID — all readable without decryption.

That command field is a PHP serialize() string. It contains every property stored in the job — public, protected, and private. The visibility modifier means nothing for serialization. If you pass a user's email, phone number, or any PII to your job constructor, that data sits in plaintext in your queue backend.

// What gets serialized — visibility does NOT protect properties
class ProcessPaymentJob implements ShouldQueue
{
    public function __construct(
        private readonly int    $customerId,    // exposed in payload
        private readonly string $email,         // exposed in payload
        private readonly float  $amount,        // exposed in payload
        private readonly string $cardLast4,     // exposed in payload!
    ) {}
}

// The serialized payload will contain all four values in plaintext
// Even though they're declared private

ShouldBeEncrypted – How It Works

ShouldBeEncrypted is a marker interface. When Laravel's queue system detects it on a job, it encrypts the entire serialized command string using Illuminate\Encryption\Encrypter with your APP_KEY before writing to the queue backend. The decryption happens in the worker before deserialization.

// Implementing ShouldBeEncrypted — one interface, full payload encryption
use Illuminate\Contracts\Queue\ShouldBeEncrypted;

class ProcessPaymentJob implements ShouldQueue, ShouldBeEncrypted
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public function __construct(
        private readonly int   $customerId,
        private readonly float $amount,
    ) {}

    public function handle(): void { /* ... */ }
}

// The encrypted payload in Redis now looks like:
{
    "uuid": "d4c8b2a1-...",
    "displayName": "App\\Jobs\\ProcessPaymentJob",
    "job": "Illuminate\\Queue\\CallQueuedHandler@call",
    "data": {
        "commandName": "App\\Jobs\\ProcessPaymentJob",
        "command": "eyJpdiI6Ik5qUDhqdW9hRWdCZ..."  // AES-256-GCM encrypted blob
    }
}

// The job class name (displayName, commandName) is NOT encrypted — only the command body.
// An attacker can see WHICH job was dispatched, but not the job's data.

The encryption uses Laravel's default cipher (AES-256-CBC or AES-256-GCM depending on config), with the APP_KEY as the encryption key. The IV (Initialization Vector) is randomized per encryption, so two encryptions of identical data produce different ciphertext. This also means encrypted jobs are not inspectable even by internal tooling unless your APP_KEY is accessible to that tooling.

// config/app.php — cipher affects encryption strength
'key'    => env('APP_KEY'),
'cipher' => 'AES-256-CBC',  // default
// OR
'cipher' => 'AES-256-GCM',  // preferred — provides authenticated encryption (AEAD)
                             // GCM detects ciphertext tampering, CBC does not

// APP_KEY rotation — if you rotate APP_KEY, existing encrypted jobs in the queue
// cannot be decrypted. They will fail with a DecryptException.
// Always drain the queue before rotating APP_KEY in production.

// What ShouldBeEncrypted does NOT protect:
// - The job class name (visible in displayName and commandName)
// - Job metadata: uuid, maxTries, timeout, backoff
// - Queue name (which queue the job is in)
// - Timestamp of dispatch
ShouldBeEncrypted encrypts payload data but does not hide job metadata. An attacker with queue access can see when jobs were dispatched, which job classes are used, and job configuration (tries, timeout). Use this interface for data confidentiality, not for hiding the existence of operations.

Preventing Job Tampering

Even without encryption, an attacker with write access to your Redis instance could modify a job's payload before the worker processes it — changing a user ID, inflating an amount, or altering account references. This is a job tampering attack.

The cleanest defense is ShouldBeEncrypted with GCM cipher: GCM provides authenticated encryption, meaning any modification to the ciphertext will cause decryption to fail with an authentication error. The worker rejects tampered jobs.

// Defense layer 1: AES-256-GCM via ShouldBeEncrypted (tamper detection built in)

// Defense layer 2: HMAC signing for non-encrypted sensitive jobs
// If you can't use ShouldBeEncrypted (e.g., job data too large, tooling compatibility),
// sign the critical fields and verify on handle():

class FinancialTransactionJob implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    private string $signature;

    public function __construct(
        private readonly int   $userId,
        private readonly float $amount,
        private readonly string $transactionType,
    ) {
        // Sign the critical fields at dispatch time using APP_KEY
        $this->signature = hash_hmac(
            'sha256',
            "{$this->userId}:{$this->amount}:{$this->transactionType}",
            config('app.key')
        );
    }

    public function handle(): void
    {
        $expectedSignature = hash_hmac(
            'sha256',
            "{$this->userId}:{$this->amount}:{$this->transactionType}",
            config('app.key')
        );

        if (! hash_equals($expectedSignature, $this->signature)) {
            $this->fail(new \RuntimeException('Job payload signature mismatch — possible tampering detected'));
            return;
        }

        // Proceed with verified data
        $this->processTransaction();
    }
}

// Defense layer 3: Always re-fetch from DB instead of trusting payload values
// Even a signed payload can be replayed. For financial operations, always fetch
// the current state from the database:
public function handle(): void
{
    $transaction = Transaction::findOrFail($this->transactionId); // re-fetch
    $this->verifyTransactionIsActionable($transaction);           // verify current state
    // Never trust amounts/IDs from the payload for financial operations
}

GDPR & Queue Jobs – The Data Minimization Problem

GDPR Article 5(1)(c) requires data minimization: personal data must be "adequate, relevant and limited to what is necessary." Job payloads that carry PII — email addresses, names, phone numbers — violate this principle if the job only needs a user ID.

The enforcement mechanism for data minimization in queues: pass references (IDs), not data. Let the job fetch the minimum required data from the source of truth (your database).

// VIOLATION: carries PII unnecessarily in the payload
class SendMarketingEmailJob implements ShouldQueue
{
    public function __construct(
        private string $email,           // PII in payload
        private string $firstName,       // PII in payload
        private string $lastName,        // PII in payload
        private array  $preferences,     // potentially PII in payload
    ) {}
}

// COMPLIANT: carries only the reference
class SendMarketingEmailJob implements ShouldQueue
{
    public function __construct(
        private readonly int $userId,    // reference only — no PII
    ) {}

    public function handle(MarketingMailer $mailer): void
    {
        // Fetch ONLY what's needed at processing time
        $user = User::select(['id', 'email', 'first_name'])->find($this->userId);

        if (! $user) {
            // User deleted between dispatch and processing — handle gracefully
            $this->delete();
            return;
        }

        $mailer->send($user);
    }
}

// For bulk email jobs where fetching N individual records is expensive,
// store the list reference server-side:
class BulkEmailCampaignJob implements ShouldQueue
{
    public function __construct(
        private readonly int $campaignId,    // reference to stored recipient list
        private readonly int $chunkOffset,   // which chunk of recipients to process
        private readonly int $chunkSize = 500,
    ) {}

    public function handle(): void
    {
        // The campaign model stores recipient IDs — fetch chunk at processing time
        Campaign::findOrFail($this->campaignId)
            ->recipients()
            ->offset($this->chunkOffset)
            ->limit($this->chunkSize)
            ->each(fn ($user) => $this->sendEmail($user));
    }
}

The Right to Erasure Problem

GDPR Article 17 (right to erasure) creates a specific challenge for queues: a user can request deletion of their data at any moment, including while a job containing their data is sitting in the queue waiting to be processed. If the job payload contains PII directly, you cannot erase it without modifying the queue entry.

The reference-only pattern solves this automatically: when a user is deleted from the database, any pending jobs that look up the user by ID will find nothing and can self-terminate cleanly.

// Handling right to erasure for pending queue jobs

// If using reference-only pattern (recommended):
class SendEmailJob implements ShouldQueue
{
    public function __construct(private readonly int $userId) {}

    public function handle(): void
    {
        $user = User::find($this->userId);

        // User was deleted (right to erasure) — job silently terminates
        if (! $user) {
            Log::info("Job aborted: user {$this->userId} no longer exists");
            $this->delete(); // remove from queue
            return;
        }

        // Proceed
    }
}

// For jobs that cannot use reference-only (rare edge cases):
// Maintain a "deleted users" set in Redis that jobs check before processing
class DeleteUserJob implements ShouldQueue
{
    public function handle(): void
    {
        // Mark user as deleted in the erasure set
        Cache::forever("erased_users:{$this->userId}", now()->toIso8601String());

        User::find($this->userId)?->forceDelete();
        // Cascade deletes to related data...
    }
}

// In any job that processes user data:
trait RespectsUserErasure
{
    protected function userWasErased(int $userId): bool
    {
        return Cache::has("erased_users:{$userId}");
    }

    protected function abortIfUserErased(int $userId): bool
    {
        if ($this->userWasErased($userId)) {
            Log::info("Job skipped: user {$userId} was erased", ['job' => static::class]);
            $this->delete();
            return true;
        }
        return false;
    }
}

// Warning: jobs encrypted with ShouldBeEncrypted that contain PII CANNOT be
// individually modified. You must drain and cancel them, or accept the TTL-based
// expiry of the queue entry as sufficient for compliance (consult your DPO).

Never Store Secrets in Job Payloads

API keys, passwords, tokens, and credentials must never be placed in job constructor arguments. This seems obvious, but it happens more often than teams realize — particularly when passing "context" to jobs for third-party API calls.

// NEVER do this:
class SyncToExternalServiceJob implements ShouldQueue
{
    public function __construct(
        private readonly int    $recordId,
        private readonly string $apiKey,      // BAD: key in payload forever
        private readonly string $apiSecret,   // BAD: rotated keys leave stale secrets in old jobs
    ) {}
}

// CORRECT: resolve credentials at runtime from a secure source
class SyncToExternalServiceJob implements ShouldQueue
{
    public function __construct(
        private readonly int $recordId,
        private readonly string $serviceSlug, // e.g., 'stripe', 'mailchimp'
    ) {}

    public function handle(ExternalServiceRegistry $registry): void
    {
        // Credentials fetched from encrypted config/secrets manager at runtime
        $credentials = $registry->getCredentials($this->serviceSlug);
        // OR from AWS Secrets Manager / HashiCorp Vault:
        $credentials = app(SecretsManager::class)->get("services.{$this->serviceSlug}");

        $service = new ExternalServiceClient($credentials);
        $service->sync(Record::findOrFail($this->recordId));
    }
}

// For temporary tokens (e.g., OAuth access tokens with short TTLs):
// Store the token in cache with appropriate TTL, pass the cache key
class SyncWithOAuthTokenJob implements ShouldQueue
{
    public int $deleteWhenMissingModels = true;

    public function __construct(
        private readonly int    $userId,
        private readonly string $tokenCacheKey,  // key to look up token, not the token itself
    ) {}

    public function handle(): void
    {
        $token = Cache::get($this->tokenCacheKey);

        if (! $token) {
            // Token expired between dispatch and processing — need re-auth
            // Notify user or trigger OAuth refresh flow
            $this->fail(new TokenExpiredException("OAuth token expired for user {$this->userId}"));
            return;
        }

        // Use token
    }
}

Securing the Queue Backend

Payload-level security (encryption, data minimization) addresses what's in the job. Backend security addresses who can read and write to the queue infrastructure itself.

Redis Security

# redis.conf – production hardening
requirepass "your-long-random-password-here"  # mandatory: require AUTH
bind 127.0.0.1 10.0.1.10                      # bind only to internal IPs — never 0.0.0.0
protected-mode yes                             # reject unauthenticated connections
rename-command FLUSHDB ""                      # disable dangerous commands
rename-command FLUSHALL ""
rename-command CONFIG ""
rename-command DEBUG ""
rename-command MONITOR ""                      # MONITOR exposes all commands in real-time

# TLS for Redis (Redis 6+)
tls-port 6380
tls-cert-file /etc/ssl/redis/redis.crt
tls-key-file  /etc/ssl/redis/redis.key
tls-ca-cert-file /etc/ssl/redis/ca.crt
tls-auth-clients yes
// Laravel Redis connection with TLS
// config/database.php
'queue-redis' => [
    'url'      => env('REDIS_URL'),
    'host'     => env('REDIS_HOST', '127.0.0.1'),
    'password' => env('REDIS_PASSWORD'),
    'port'     => env('REDIS_PORT', 6380),
    'database' => 1,
    'options'  => [
        'ssl' => [
            'cafile'       => '/etc/ssl/redis/ca.crt',
            'verify_peer'  => true,
            'verify_peer_name' => true,
        ],
    ],
],

Database Queue Security

-- If using the database queue driver, restrict access to the jobs table
-- Create a dedicated queue user with minimum permissions

CREATE USER 'queue_user'@'%' IDENTIFIED BY 'strong-random-password';

-- Only needs access to queue-related tables
GRANT SELECT, INSERT, UPDATE, DELETE ON your_app.jobs TO 'queue_user'@'%';
GRANT SELECT, INSERT, UPDATE, DELETE ON your_app.failed_jobs TO 'queue_user'@'%';
GRANT SELECT, INSERT ON your_app.job_batches TO 'queue_user'@'%';

-- NOT granted: DROP, CREATE, ALTER, or access to other tables
FLUSH PRIVILEGES;

-- In .env for workers only (not the web app):
DB_USERNAME=queue_user
DB_PASSWORD=strong-random-password
// Additional: audit logging for failed jobs with sensitive payloads
// App\Listeners\LogFailedEncryptedJob.php

class LogFailedEncryptedJob
{
    public function handle(JobFailed $event): void
    {
        // Log the failure WITHOUT the payload (which may be encrypted)
        Log::error('Queue job failed', [
            'connection'    => $event->connectionName,
            'queue'         => $event->job->getQueue(),
            'job_id'        => $event->job->getJobId(),
            'display_name'  => $event->job->payload()['displayName'] ?? 'unknown',
            'exception'     => $event->exception->getMessage(),
            // Deliberately NOT logging $event->job->getRawBody() — payload stays private
        ]);
    }
}

Conclusion

Queue security is a multi-layer problem. Each layer addresses a different threat:

  • Data minimization (pass IDs, not PII) — eliminates most privacy risk at the source. Fixes the right-to-erasure problem automatically.

  • ShouldBeEncrypted with AES-256-GCM — protects payload confidentiality and provides tamper detection. Use for any job that must carry sensitive data.

  • No secrets in payloads — credentials belong in your secrets manager, not in serialized job properties.

  • Redis AUTH + TLS + IP binding — prevents unauthorized queue access at the infrastructure level.

  • Database queue ACLs — minimal privileges for the queue database user.

The reference-only pattern (pass IDs, fetch data in handle()) is the most valuable change you can make. It simultaneously addresses GDPR compliance, right to erasure, payload size, and the risk of stale data being processed from an old payload. Encrypt where necessary, but minimizing what's in the payload is always better than encrypting everything.

📑 On This Page