# 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.

**Author:** Matt King | **Published:** July 14, 2026 | **Category:** Security

---

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:

```php
// 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.

```php
// 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.

```sql
-- 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](/blog/php-insecure-deserialization-laravel) 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.

```ini
# 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](/blog/laravel-ssrf-http-client-vulnerability); the short version is that the validation has to live where the request is actually made.

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

```php
// 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](/blog/laravel-api-security-rate-limiting-sanctum).

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

```php
// 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.

```php
// 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](/free-scan) and we will surface the unencrypted jobs, the open backends and the worker permissions worth tightening.

---

## 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.

