# Insecure Deserialization in PHP: unserialize() Risks Beyond Reverb

> A single unserialize() call on attacker-controlled input can turn a vendor class into a remote code execution chain. Here is how PHP object injection works in Laravel apps, why your session cookies are usually safe, and the exact code changes that close the hole.

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

---

Your Laravel API has authentication. Tokens are required. HTTPS is enforced. You might think you are covered, but one function call can hand an attacker the ability to run code on your server, and it has nothing to do with your login form. That function is `unserialize()`, and the way PHP rebuilds objects from a string is a vulnerability class that has been quietly burning teams for over a decade.

The Laravel Reverb remote code execution bug tracked as [CVE-2026-23524](/blog/laravel-reverb-rce-cve-2026-23524) put this back in the spotlight. It was an insecure deserialisation flaw in a first-party Laravel package, not some abandoned dependency. If it can happen there, it can happen in the cache layer you wrote last sprint.

---

## What insecure deserialization actually is

Serialisation turns a PHP value into a string you can store or transmit. `serialize($user)` gives you a flat representation of that object, including its class name and every property. `unserialize()` reverses the process: it reads the class name out of the string, constructs an instance, and sets the properties back.

That second step is the whole problem. `unserialize()` does not ask whether you wanted an object of that class. It reads whatever class name sits in the string and builds it, populating the properties with whatever values the string specifies. If the string came from an attacker, the attacker chooses the class and the property values.

On its own, instantiating an object is harmless. The danger comes from PHP's magic methods. When an object is rebuilt, PHP may call `__wakeup()`. When it is later destroyed, PHP calls `__destruct()`. If the object is ever cast to a string, `__toString()` fires. These methods run automatically, with property values the attacker controls.

Now picture a class in your vendor tree whose `__destruct()` writes a property to a file path held in another property. An attacker serialises that class with the file path set to `routes/web.php` and the contents set to a webshell. The moment the crafted object goes out of scope, PHP writes the file. Nobody called a dangerous function directly. The attacker simply supplied data, and your own code did the rest.

Stitching several such classes together is called a POP gadget chain, short for Property-Oriented Programming. One gadget's magic method calls a method on a property, which is itself a crafted object whose method calls another, and so on, until the chain lands on something useful: a file write, a SQL query, or `call_user_func` with attacker-chosen arguments. Tools like PHPGGC ship ready-made chains for popular libraries, including ones that ship with Laravel.

---

## Why json_decode is not the same thing

The contrast with JSON is the key insight, so it is worth being precise about it.

`json_decode()` can only ever produce four kinds of value: scalars, arrays, `stdClass` objects, and `null`. It never reads a class name from the input, because JSON has no concept of one. It never instantiates `App\Models\User` or some `Symfony\Component` gadget. With no application objects, there are no magic methods to trigger and no chain to build.

| Function | Reconstructs your classes? | Triggers magic methods? | Gadget chain risk |
| --- | --- | --- | --- |
| `unserialize($data)` | Yes, any loaded class | Yes (`__wakeup`, `__destruct`, `__toString`) | High |
| `unserialize($data, ['allowed_classes' => false])` | No, objects become `__PHP_Incomplete_Class` | No | Low |
| `json_decode($data)` | No, only `stdClass` and arrays | No | None |

That table is the entire defence strategy in three rows. Move untrusted data to the bottom row wherever you can. Where you genuinely cannot, the middle row is your floor.

---

## Where this shows up in real Laravel apps

The Reverb CVE was in framework code, but most of the risk in your app is code you wrote. These are the patterns that come up again and again in audits.

**Reading serialized request input.** Someone needed to round-trip a complex object through a form or an API call, reached for `serialize()` on the way out, and `unserialize()` on the way back in.

```php
// app/Http/Controllers/WizardController.php
public function resume(Request $request)
{
    // VULNERABLE: the client controls 'state' completely
    $state = unserialize($request->input('state'));

    return view('wizard.step', ['state' => $state]);
}
```

The `state` field is a plain request parameter. An attacker sends a serialised gadget chain instead of your wizard state, and you have handed them object instantiation.

**Reading serialized cookies directly.** Bypassing Laravel's cookie handling and touching the superglobal is rarer but far worse, because it skips the encryption that would normally save you.

```php
// VULNERABLE: $_COOKIE is never decrypted or signature-checked
$prefs = unserialize($_COOKIE['user_prefs']);
```

**Custom cache layers.** Hand-rolled caching that stores serialised blobs is fine until the backing store is something an attacker can write to, such as a shared Redis instance, a database row reachable through another bug, or a file in a world-writable directory.

**Queue payloads on an attacker-writable backend.** Laravel serialises queued jobs. If your queue backend can be written to by an untrusted party, a poisoned payload becomes code execution when a worker picks it up. This is one of several reasons to lock down your queue infrastructure, covered in [securing Laravel queues and background jobs](/blog/securing-laravel-queues-background-jobs).

**Imported serialized data from files or uploads.** A "restore from backup" feature or a plugin import format that uses `serialize()` turns an upload into an injection vector the instant the file is processed.

---

## Why Laravel's own cookies are usually safe

Laravel does serialise data into cookies and the session, so a fair question is why those are not a constant source of these bugs.

The answer is the `EncryptCookies` middleware. Before any cookie leaves the server, Laravel encrypts it and attaches a message authentication code derived from your `APP_KEY`. On the way back in, the framework verifies that MAC before it decrypts and unserialises anything. A tampered or forged cookie fails the check and is discarded, so the malicious serialised payload never reaches `unserialize()`.

```php
// app/Http/Kernel.php — part of the default 'web' middleware group
protected $middlewareGroups = [
    'web' => [
        \App\Http\Middleware\EncryptCookies::class,
        // ...
    ],
];
```

This is also why bypassing the framework with raw `$_COOKIE` access, as in the earlier example, is so dangerous: it sidesteps the signature check entirely.

The protection rests on one assumption: `APP_KEY` is secret. An attacker who learns your key can forge a cookie with a valid MAC, and the encrypt-then-verify dance happily accepts it. At that point the serialised object inside is trusted and reconstructed. A leaked key turns a non-issue back into a full object-injection vulnerability, which is one more reason a leaked `APP_KEY` sits among the worst configuration mistakes in the [OWASP Top 10 for Laravel](/blog/owasp-top-10-laravel). Treat it as a credential, keep it out of version control, and rotate it if it is ever exposed.

---

## Fixing it

There is a clear priority order. Prefer the option highest on this list that your use case allows.

**1. Use JSON instead of PHP serialisation.** For data crossing a trust boundary, this is almost always the right answer. Here is the wizard controller again, fixed.

```php
// app/Http/Controllers/WizardController.php
public function resume(Request $request)
{
    $validated = $request->validate([
        'state' => ['required', 'string'],
    ]);

    // FIXED: json_decode never instantiates your classes
    $state = json_decode($validated['state'], associative: true);

    if (! is_array($state)) {
        abort(422, 'Invalid wizard state.');
    }

    return view('wizard.step', ['state' => $state]);
}
```

When you produce the data, pair it with `json_encode()` rather than `serialize()`. You give up rich object round-tripping, but data coming back from a client should be plain structured values anyway.

**2. If you must unserialize, forbid object reconstruction.** When a third-party format genuinely uses PHP serialisation and you cannot change it, pass the `allowed_classes` option. Setting it to `false` means any object in the payload becomes a harmless `__PHP_Incomplete_Class` with no magic methods, killing the gadget chain.

```php
// FIXED: no real classes are instantiated, so no gadgets fire
$data = unserialize($blob, ['allowed_classes' => false]);

// Or allow a strict, vetted set if you truly need specific objects:
$data = unserialize($blob, ['allowed_classes' => [DateTimeImmutable::class]]);
```

Never pass a class you do not fully trust the magic methods of, and that includes most vendor classes.

**3. Sign any serialised blob you control.** If your own code writes a serialised value to a store and reads it back, attach an HMAC so you can detect tampering before unserialising, exactly as Laravel does for cookies.

```php
// app/Support/SignedBlob.php
final class SignedBlob
{
    public function __construct(private string $key) {}

    public function pack(array $data): string
    {
        $payload = json_encode($data, JSON_THROW_ON_ERROR);
        $mac = hash_hmac('sha256', $payload, $this->key);

        return $mac . '|' . $payload;
    }

    public function unpack(string $blob): array
    {
        [$mac, $payload] = explode('|', $blob, 2) + ['', ''];

        $expected = hash_hmac('sha256', $payload, $this->key);

        if (! hash_equals($expected, $mac)) {
            throw new \RuntimeException('Blob signature mismatch.');
        }

        return json_decode($payload, associative: true, flags: JSON_THROW_ON_ERROR);
    }
}
```

Note the use of `hash_equals()` for the comparison, which avoids the timing leak a plain `===` would introduce, and the use of JSON inside the envelope so that even a forged-but-impossible payload could not carry an object.

**4. Keep dependencies patched.** Gadget chains live in vendor code. Even if every `unserialize()` call in your own app is locked down, a vulnerable library plus an injection point elsewhere can still be chained. Running `composer audit` regularly and keeping packages current closes the gadgets attackers rely on. The [Composer vulnerability management](/blog/composer-vulnerability-management) post covers how to wire this into CI so a known-vulnerable dependency fails the build.

**5. Validate and authenticate at every trust boundary.** None of the above replaces checking that data is what you expect. Validate shape and type after decoding, and make sure the request that delivered the data was authenticated and authorised in the first place.

---

## A quick reference for input sources

When you are triaging a codebase, the source of the data tells you almost everything about how to handle it.

| Input source | Attacker controlled? | Safe handling |
| --- | --- | --- |
| Request body or query string | Yes | `json_decode`, then validate |
| Raw `$_COOKIE` access | Yes | Never `unserialize`; go through Laravel's encrypted cookies |
| Laravel encrypted cookie or session | No, while `APP_KEY` is secret | Default framework handling is safe |
| Uploaded or imported file | Yes | `json_decode` or `allowed_classes => false` |
| Custom cache on shared backend | Maybe | JSON, or HMAC-sign the blob |
| Queue payload on untrusted backend | Maybe | Lock down the backend; treat payloads as untrusted |
| Serialised value you wrote and only you can read | No | `unserialize` acceptable, sign for defence in depth |

The middle column is the question that matters. If the answer is yes or maybe, the data crosses a trust boundary and the rules above apply.

---

## What to do this week

Run one command across your whole repository, vendor directory included:

```bash
grep -rn 'unserialize(' .
```

Walk every hit. For each, trace the argument back to its origin. If it can be reached by a request, a cookie, a file, a cache, or a queue an attacker can touch, move it to `json_decode` or, where that is impossible, add `['allowed_classes' => false]`. Calls on genuinely internal, trusted data can stay. That single sweep removes the most direct path an attacker has from your inputs to your shell.

Want the same sweep run automatically, plus your dependency tree checked against known gadget-bearing libraries and your `APP_KEY` handling verified? [Run a free StackShield scan](/free-scan) and get the list of every risky `unserialize()` call in minutes.

---

## Frequently Asked Questions

### Is unserialize() always dangerous in PHP?

It is dangerous whenever the input can be influenced by an attacker. Calling unserialize() on data you generated yourself and stored somewhere only you control is fine. The problem starts when the serialized string comes from a request body, a cookie, an uploaded file, or any cache or queue backend that an attacker can write to. In those cases PHP will happily reconstruct arbitrary objects of any class loaded in your app, and that is the foothold gadget chains are built on.

### What is a POP gadget chain?

POP stands for Property-Oriented Programming. A gadget chain stitches together the magic methods of classes already present in your codebase or vendor tree so that reconstructing a crafted object triggers a useful side effect. An attacker sets object properties through the serialized payload, and when PHP calls __destruct, __wakeup or __toString during or after unserialisation, those properties steer the code towards a file write, a database query, or command execution. The attacker writes no new code; they reuse yours.

### Are Laravel session and cookie values vulnerable to object injection?

Usually not, because the EncryptCookies middleware encrypts and signs every cookie with your APP_KEY before it leaves the server, and verifies the signature on the way back in. Tampered or forged serialized payloads fail the MAC check and are rejected before any unserialize() runs. That protection collapses the moment APP_KEY leaks, because an attacker who knows the key can forge a valid signed cookie containing a malicious object. Keeping APP_KEY secret is therefore part of your deserialisation defence, not just your encryption story.

### Does json_decode() have the same risk as unserialize()?

No. json_decode() can only produce arrays, scalars, and stdClass objects. It never instantiates one of your application or vendor classes, so there are no magic methods to trigger and no gadget chain to build. That is exactly why swapping serialize and unserialize for json_encode and json_decode is the single most effective fix for most cases. You lose the ability to round-trip rich PHP objects, but for data crossing a trust boundary that is a feature, not a limitation.

### How do I find insecure deserialization in my own codebase?

Grep the whole project, including the vendor directory, for the literal unserialize( and review every hit. For each call, trace where the argument comes from and ask whether an attacker can influence it through a request, cookie, file, cache, or queue. Calls on trusted internal data can stay; calls on anything crossing a trust boundary need to move to json_decode or at minimum pass the allowed_classes option. A StackShield scan flags these patterns automatically so you can triage them without reading every file by hand.

