Security 11 min read Markdown

Securing Laravel Queues and Background Jobs

Your queue runs code with no user watching, often with elevated access, on payloads sitting in plaintext. Here is how to encrypt sensitive jobs, lock down the backend, validate user input inside handle(), and keep failed_jobs from leaking your secrets.

Matt King
Matt King
July 14, 2026
Last updated: July 14, 2026
Securing Laravel Queues and Background Jobs

Your Laravel app validates every controller. Form requests guard the input. Policies gate the actions. You might think the dangerous code paths are covered, but the queue runs your code somewhere else entirely, with no user in front of it and frequently with more access than a web request ever gets.

A job is just serialised PHP sitting in a store, waiting for a worker to pick it up, deserialize it and run it. That store might be your database, Redis or SQS. The payload might hold a customer's email, an API token or a whole model. And the worker that executes it usually has a long-lived process, broad credentials and nobody watching the output. That combination is exactly what makes queues an attack surface people forget about.


Why the queue is its own attack surface

Think about what actually happens when you dispatch a job. Laravel serialises the job object, including every argument you passed into its constructor, and writes that blob to the backend. Some time later a worker reads it back, calls unserialize, rebuilds the object and invokes handle(). Three properties of that flow matter for security.

The payload is stored, not transient. Unlike a request that lives for a few hundred milliseconds, a queued job sits in your database or Redis until a worker gets to it, and a failed one can live in failed_jobs for weeks. Whatever you put in the arguments is at rest, in plaintext, for that whole window.

Deserialization runs code. unserialize on attacker-controlled data is one of the oldest remote-code-execution primitives in PHP. If anyone can write to the backend, the worker becomes the thing that executes their payload.

Workers run with elevated trust. A queue worker is a long-running CLI process. It often connects to the database with broader credentials than the web app, can reach internal services and has no per-request user context to constrain it. A job that fetches a URL or runs a command does so as the worker, not as the user who triggered it.

None of this is exotic. It is the normal way queues work. The job is to treat that pipeline with the same suspicion you apply to an HTTP endpoint.


Keep sensitive data out of the payload

The most common queue leak is mundane: a job that carries personal data or a token, written straight into the store in the clear.

Picture a job that emails a password reset link and, for convenience, takes the whole user and the raw token:

// app/Jobs/SendPasswordReset.php — leaks the token into the queue store
class SendPasswordReset implements ShouldQueue
{
    public function __construct(
        public User $user,
        public string $plainToken,
    ) {}

    public function handle(): void
    {
        Mail::to($this->user)->send(new PasswordResetMail($this->plainToken));
    }
}

On the database driver, $plainToken is now a readable string in the jobs table. On Redis it is a field in a JSON blob. Anyone with read access to either, including a backup that walked out the door, can lift it.

Two fixes stack here. First, encrypt the payload by implementing ShouldBeEncrypted. Laravel encrypts the serialised job with your APP_KEY before storing it and decrypts it on the worker, so the at-rest blob is unreadable.

// app/Jobs/SendPasswordReset.php — payload encrypted at rest
use Illuminate\Contracts\Queue\ShouldBeEncrypted;

class SendPasswordReset implements ShouldQueue, ShouldBeEncrypted
{
    use Queueable;

    public function __construct(
        public int $userId,
        public string $plainToken,
    ) {}

    public function handle(): void
    {
        $user = User::findOrFail($this->userId);
        Mail::to($user)->send(new PasswordResetMail($this->plainToken));
    }
}

Notice the second change: it takes a $userId, not a User. Pass identifiers and re-fetch inside handle() rather than shipping whole models around. The job acts on the current state of the row, not a snapshot that might be minutes stale, and you avoid serialising every attribute including hidden ones.

If you do want to pass a model, the SerializesModels trait is your friend. It stores only the model class and primary key in the payload, then reloads the record when the worker runs. That keeps the actual column values out of the queue store. The trait comes in when you scaffold a job with php artisan make:job, so most jobs already have it.

A quick way to see what is actually being stored: dispatch the job and read the row.

-- See the raw payload on the database queue driver
SELECT payload FROM jobs ORDER BY id DESC LIMIT 1;

If you can read your token, your customer's email or an internal note in that column, so can anyone else with database access. Encrypt the job or stop passing the data.

Since the encryption is tied to APP_KEY, a leaked or default key undoes all of it. We have written separately about why a misconfigured APP_KEY is its own vulnerability and how object injection works once an attacker controls serialised input.


The deserialization trap on an exposed backend

Here is the scenario that turns a forgotten config into remote code execution. Your Redis instance is bound to 0.0.0.0, has no password, and a security group rule got loosened during a debugging session. An attacker scans the port, connects, and pushes their own entry onto the list your workers read.

When the worker pulls that entry, it calls unserialize on the attacker's string. If the payload references a class in your codebase or its dependencies whose __destruct or __wakeup method does something useful to the attacker, that code runs. This is PHP object injection, and a queue worker is an ideal target because it runs unattended and with real credentials.

The defence is not clever sanitisation of the payload. It is denying write access to the backend in the first place.

# redis.conf — close the obvious holes
bind 127.0.0.1 ::1
protected-mode yes
requirepass a-long-random-secret-not-this

The rules are short and non-negotiable:

  • Never expose Redis to the public internet. Bind it to 127.0.0.1 or a private network interface that only your app servers can reach.
  • Require authentication. Set requirepass, or use Redis 6+ ACLs to give the queue its own user with only the commands it needs.
  • Use TLS when the workers and Redis talk across any network you do not fully control, so the password and payloads are not sniffable.
  • Lock down SQS and the database driver too. SQS queues should have a tight IAM policy; the database queue should sit behind a private subnet, not a public endpoint.

If an attacker cannot write to the queue, they cannot feed your workers a malicious payload, and the object-injection path is closed. Everything else is defence in depth on top of that.


Treat user input in a job like input in a controller

A job that acts on data a user supplied is a request handler that happens to run later. It deserves the same validation. The classic mistake is assuming the controller already checked everything, so the job can trust its arguments.

The riskiest jobs are the ones that reach out into the world on the user's behalf: fetching a URL, running a shell command, writing a file to a path. A job that fetches a user-supplied URL is a server-side request forgery waiting to happen, because the worker can usually reach internal services the user never could. We go deep on that pattern in the post on SSRF through Laravel's HTTP client; the short version is that the validation has to live where the request is actually made.

So validate inside handle(), not only at dispatch:

// app/Jobs/ImportRemoteAvatar.php — validates the URL where it is used
use Illuminate\Support\Facades\Validator;

class ImportRemoteAvatar implements ShouldQueue
{
    use Queueable;

    public function __construct(
        public int $userId,
        public string $sourceUrl,
    ) {}

    public function handle(): void
    {
        $validator = Validator::make(
            ['url' => $this->sourceUrl],
            ['url' => ['required', 'url:http,https']],
        );

        if ($validator->fails()) {
            $this->fail('Rejected avatar URL: ' . $this->sourceUrl);
            return;
        }

        $host = parse_url($this->sourceUrl, PHP_URL_HOST);

        // Block private and loopback ranges so the worker cannot be
        // used to reach internal services.
        foreach (gethostbynamel($host) ?: [] as $ip) {
            if (! filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) {
                $this->fail('Avatar host resolves to a private address.');
                return;
            }
        }

        $body = Http::timeout(10)->get($this->sourceUrl)->throw()->body();

        Storage::disk('public')->put("avatars/{$this->userId}.jpg", $body);
    }
}

The same discipline applies to a job that runs a process. Never interpolate a job argument into a shell string. Pass an array of arguments to Process::run so there is no shell to inject into, and allow-list the values you accept rather than trying to escape them.


Controls against abuse and runaway jobs

A user who can trigger a dispatch can usually trigger a lot of them. If a button enqueues a job and there is no limit, a script can enqueue ten thousand in a loop and bury your workers. Rate-limit the dispatch the same way you would rate-limit the endpoint behind it. Laravel's API throttling carries straight over; the patterns are the same ones in the Sanctum rate limiting guide.

On the worker side, Laravel gives you middleware and properties to keep a single class of job from running away.

// app/Jobs/GenerateReport.php — bounded retries, timeout and no overlap
use Illuminate\Queue\Middleware\WithoutOverlapping;
use Illuminate\Queue\Middleware\RateLimited;

class GenerateReport implements ShouldQueue
{
    use Queueable;

    public int $tries = 3;
    public int $maxExceptions = 2;
    public int $timeout = 120;

    public function __construct(public int $accountId) {}

    public function middleware(): array
    {
        return [
            (new WithoutOverlapping($this->accountId))->releaseAfter(30),
            new RateLimited('reports'),
        ];
    }

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

WithoutOverlapping keyed on the account stops the same heavy job running twice at once for one tenant. RateLimited ties into a named limiter you define in a service provider, capping how fast that class of job runs across the fleet. The properties bound failure: $tries and $maxExceptions stop a poisoned job retrying forever, and $timeout kills a job that hangs so it cannot pin a worker indefinitely.

For finer control you can throttle directly with Redis::throttle inside the job when the work talks to a rate-limited third-party API. The principle is the same in every case: put a ceiling on it so one input cannot consume the whole queue.


failed_jobs hygiene

When a job throws past its retries, Laravel records it in failed_jobs with two things you do not want lying around: the full payload and the exception stack trace. The payload carries whatever arguments the job had, and the trace carries internal file paths, query fragments and sometimes the very values that caused the failure.

So a single failed_jobs row can hold a customer's email from the payload and a chunk of SQL with their data from the trace. Treat the table as sensitive storage.

  • Restrict access. No admin panel should surface raw failed_jobs rows without tight authorisation, and the table should not be readable by reporting or analytics credentials.
  • Prune on a schedule. Old failures have no value after you have triaged them. Clear them automatically.
  • Encrypt the jobs that carry secrets. A job implementing ShouldBeEncrypted stores an encrypted payload even when it fails, so the stored arguments stay unreadable.
// routes/console.php — prune failed jobs older than two weeks
use Illuminate\Support\Facades\Schedule;

Schedule::command('queue:prune-failed --hours=336')->daily();

Least privilege for workers

A queue worker rarely needs everything the web app needs. It usually writes to a handful of tables and calls a couple of services. Give it credentials scoped to exactly that.

Run workers under a database user that cannot touch tables the jobs never use, and certainly cannot run DDL. If the worker only sends mail and updates a notifications table, it has no business holding credentials that can drop the users table. The same goes for cloud access: the worker's IAM role should grant the specific S3 bucket or SQS queue it uses, nothing wider.

The point is blast radius. If a job is compromised through a deserialization bug or a logic flaw, the damage is bounded by what the worker's credentials allow. Narrow them.


Risk and control at a glance

Risk Control
PII or tokens stored in plaintext payloads Implement ShouldBeEncrypted; pass IDs and re-fetch
Whole models serialised with hidden fields SerializesModels (stores class + key) or pass IDs
Object injection on an exposed backend requirepass/ACLs, bind to private network, TLS
SSRF or command injection from job arguments Validate inside handle(); allow-list; array-form Process
Queue flooding from user-triggered dispatch Rate-limit dispatch; WithoutOverlapping, RateLimited
Jobs retrying forever or hanging $tries, $maxExceptions, $timeout
Secrets and traces in failed_jobs Restrict access; queue:prune-failed; encrypt jobs
Worker over-privileged Scoped DB user and IAM role per worker

Where to start

If you do two things this week, do these: add ShouldBeEncrypted to every job that touches a token, a credential or personal data, and confirm your Redis or SQS backend cannot be written to by anyone outside your private network. Those two changes close the plaintext leak and the remote-code-execution path, which are the two failures that turn an overlooked queue into a breach.

Not sure which of your jobs carry sensitive payloads or whether your queue backend is exposed? Run a free StackShield scan and we will surface the unencrypted jobs, the open backends and the worker permissions worth tightening.

Free security check

Is your Laravel app exposed right now?

34% of Laravel apps we scan have at least one critical issue. Most teams don't find out until something breaks. Our free scan checks your live application in under 60 seconds.

18% have debug mode on
72% missing security headers
12% have exposed .env
Scan My App Free No signup required. Results in 60 seconds.

Frequently Asked Questions

Are Laravel queue job payloads encrypted by default?

No. By default the serialised job, including all its constructor arguments, is stored as plaintext in your queue backend. On the database driver that means a readable column in the jobs table; on Redis it sits in a list as a JSON blob. Anyone with read access to that store can see whatever you passed into the job. To encrypt the payload you implement the ShouldBeEncrypted interface on the job, which tells Laravel to encrypt it with your APP_KEY before storing and decrypt it on the worker.

Should I pass an Eloquent model or just its ID into a job?

Pass the ID and re-fetch inside the job whenever you can. If you use the SerializesModels trait, Laravel is clever about it and stores only the model class and primary key rather than the full attributes, then reloads the record when the job runs. That keeps sensitive columns out of the payload and means the job acts on the current state of the row rather than a stale snapshot. If you bypass that and pass a raw array of attributes, every field including hidden ones gets serialised into the queue store.

How can an exposed Redis lead to remote code execution?

If Redis has no password and is reachable from the network, an attacker can write directly to the list your workers read from. They craft a serialised PHP payload that, when unserialized by the worker, instantiates objects whose destructors or wakeup methods do something dangerous. That is classic PHP object injection, and the worker often runs with more privileges than a web request. Requiring a password, binding Redis to localhost or a private network, and enabling TLS removes the write access the attack depends on.

How do I stop a user from flooding my queue with jobs?

Rate-limit the dispatch, not just the execution. If a user action triggers a job, apply the same throttling you would on any endpoint so one person cannot enqueue ten thousand jobs in a loop. On the worker side, WithoutOverlapping stops the same job running concurrently and Redis::throttle or the RateLimited middleware caps how fast a class of jobs runs. Combine that with sensible $tries, $maxExceptions and $timeout values so a single poisoned job cannot retry forever.

Is the failed_jobs table a security risk?

It can be. A failed job stores its full payload and the exception stack trace, so any PII, token or credential that was in the job arguments ends up sitting in that table along with internal paths and SQL fragments from the trace. Treat it like a log of sensitive data: restrict who can query it, prune it on a schedule with queue:prune-failed, and encrypt the jobs that carry secrets so the stored payload is unreadable. Never expose it through an admin panel without tight access control.

Stay Updated on Laravel Security

Get actionable security tips, vulnerability alerts, and best practices for Laravel apps.