Security: CSRF, Encryption and Hashing

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Laravel 13.x (13.34.0) on PHP 8.3 to 8.5, checked on 2026-10-05. Laravel turns most of the defences that a hand-written PHP application has to build itself (Web Security Fundamentals) into defaults: request forgery checks in the web group, escaped Blade output, an authenticated encrypter, slow password hashes and signed URLs. This page runs each of them against Bookshelf, shows what each one blocks, and notes where a default still leaves a gap.

Request forgery protection

The PreventRequestForgery middleware (named VerifyCsrfToken before Laravel 13) runs in the web group. A POST, PUT, PATCH or DELETE request passes if any of these checks pass, in this order:

  1. The request is a read (GET, HEAD, OPTIONS), or the tests are running.

  2. The URI is in the except list.

  3. Origin check: the browser’s Sec-Fetch-Site header is same-origin, or same-site when allowSameSite is on.

  4. Token check: _token in the body, or the X-CSRF-TOKEN header, or the encrypted X-XSRF-TOKEN header, equals the session’s token (compared with hash_equals).

A Bookshelf form for short notes, with @csrf writing the hidden _token field:

<?php
// routes/web.php
Route::get('/shelf/notes', fn () => view('notes'));
Route::post('/shelf/notes', fn (Illuminate\Http\Request $request) => ['saved' => $request->string('note')->limit(40)])
    ->middleware('throttle:notes');
{{-- resources/views/notes.blade.php --}}
<form method="POST" action="/shelf/notes">
    @csrf
    <input name="note">
</form>

Each way of sending the request, with a session cookie from a previous GET in the jar:

$ curl -s -c jar -b jar localhost:8000/shelf/notes > /dev/null
$ curl -s -o /dev/null -w '%{http_code}\n' -c jar -b jar -d note=hi localhost:8000/shelf/notes
419
$ curl -s -o /dev/null -w '%{http_code}\n' -c jar -b jar -H 'Sec-Fetch-Site: cross-site' -d note=hi localhost:8000/shelf/notes
419
$ curl -s -c jar -b jar -H 'Sec-Fetch-Site: same-origin' -d note=hi localhost:8000/shelf/notes
{"saved":"hi"}
$ curl -s -c jar -b jar -d "note=hi&_token=$TOKEN" localhost:8000/shelf/notes        # $TOKEN read from the form, 40 chars
{"saved":"hi"}
$ curl -s -c jar -b jar -H "X-XSRF-TOKEN: $XSRF" -d note=hi localhost:8000/shelf/notes  # $XSRF = XSRF-TOKEN cookie, URL-decoded
{"saved":"hi"}

The run shows two things worth knowing:

  • The origin check trusts the header. The middleware does not check that the connection is HTTPS. It accepts Sec-Fetch-Site: same-origin from any client, so curl passes without a token. This is safe, because the attack needs the victim’s own browser, and a page cannot set Sec-Fetch-* headers from script. Browsers send the header only to secure origins (HTTPS or localhost), so an HTTP site falls back to the token check. Request forgery protection guards against cross-site browsers. It is not authentication for API clients.

  • A 419 is a TokenMismatchException. Laravel renders it as "Page Expired", and the usual cause is a session that expired while the form was open. The error handling page shows how to redirect back with a message instead (Error Handling and Logging).

Every current browser sends Sec-Fetch-Site (Safari was the last to add it, in 16.4 in March 2023), so an application can turn the token fallback off. In origin-only mode, a request without the header gets a 403 instead of a 419, and the XSRF-TOKEN cookie is no longer set:

<?php
// bootstrap/app.php
return Application::configure(basePath: dirname(__DIR__))
    // ... withRouting() and withExceptions() unchanged ...
    ->withMiddleware(function (Middleware $middleware): void {
        $middleware->preventRequestForgery(originOnly: true);
        // or: allowSameSite: true for dashboard.example.com posting to example.com
        // or: except: ['stripe/*'] for webhooks, which also need their own signature check
    })->create();
$ curl -s -o /dev/null -w '%{http_code}\n' -c jar2 -b jar2 -d note=hi localhost:8000/shelf/notes
403
$ curl -s -o /dev/null -w '%{http_code}\n' -c jar2 -b jar2 -H 'Sec-Fetch-Site: same-origin' -d note=hi localhost:8000/shelf/notes
200

Origin-only mode suits an application that is served only over HTTPS and has no very old browsers to support. A single-page application on another subdomain uses Sanctum’s CSRF cookie flow instead (API Authentication: Sanctum and Passport).

Encryption

Crypt (the encrypter service) encrypts with AES-256-CBC under APP_KEY and adds an HMAC-SHA256 MAC, so any change to the ciphertext is detected. Laravel encrypts cookies, the encrypted model casts and ShouldBeEncrypted jobs with it. The output is base64 of a JSON object holding the IV, the ciphertext, the MAC and an empty tag (used only by the GCM ciphers):

<?php
use Illuminate\Contracts\Encryption\DecryptException;
use Illuminate\Support\Facades\Crypt;

$payload = Crypt::encryptString('member 42 note');
$json = json_decode(base64_decode($payload), true);   // iv, value, mac, tag
echo Crypt::decryptString($payload), PHP_EOL;

$json['value'][5] = $json['value'][5] === 'A' ? 'B' : 'A';   // flip one character
try {
    Crypt::decryptString(base64_encode(json_encode($json)));
} catch (DecryptException $e) {
    echo $e->getMessage(), PHP_EOL;
}

Crypt::encryptString('x') === Crypt::encryptString('x');      // false: a fresh IV every time
Crypt::decrypt(Crypt::encrypt(['id' => 42]));                 // encrypt() serializes, encryptString() does not
cipher: AES-256-CBC
length: 200
  iv: 24 chars
  value: 24 chars
  mac: 64 chars
  tag: 0 chars
decrypt: member 42 note
tampered: The MAC is invalid.
same plaintext twice equal? false
encrypt(array) round trip: {"id":42}

The 14-byte note became 200 characters. Encrypted columns therefore need text, not string(255), once the value is longer than a few dozen characters.

Rotating APP_KEY

Changing APP_KEY makes every existing ciphertext unreadable, including the session and remember-me cookies, so all users are logged out. APP_PREVIOUS_KEYS takes a comma-separated list of old keys. New data is encrypted with the current key, and decryption tries the old keys when the current key fails. A payload written under one key, read back in three runs:

$ APP_KEY=$OLD php rot.php write
written
$ APP_KEY=$NEW php rot.php
Illuminate\Contracts\Encryption\DecryptException: The MAC is invalid.
$ APP_KEY=$NEW APP_PREVIOUS_KEYS=$OLD php rot.php
encrypted under the old key

Old keys can only be dropped from the list when nothing encrypted under them is left. Cookies and sessions expire by themselves, but encrypted casts in the database must be re-saved first. APP_KEY is not a password-hashing key, so rotating it does not affect stored password hashes.

Hashing passwords

Hash::make uses bcrypt by default, with BCRYPT_ROUNDS=12 in a new .env. Argon2id is the other choice (HASH_DRIVER=argon2id). Timings on this machine, the rehash check, and two edge cases:

<?php
use Illuminate\Support\Facades\Hash;

$phrase = 'correct horse battery staple';
foreach ([10, 12, 14] as $rounds) {
    $hash = Hash::make($phrase, ['rounds' => $rounds]);      // each round doubles the work
}
$hash = Hash::make($phrase);
Hash::check($phrase, $hash);                                   // true
Hash::needsRehash($hash);                                      // false at 12 rounds, true after raising it to 13

$argon = Hash::driver('argon2id')->make($phrase);
Hash::check($phrase, $argon);                                  // RuntimeException with the bcrypt driver
Hash::check(str_repeat('a', 72).'DIFFERENT', Hash::make(str_repeat('a', 72).'tail'));   // true: bcrypt reads 72 bytes
bcrypt rounds 10: $2y$10$… 60 ms
bcrypt rounds 12: $2y$12$… 231 ms
bcrypt rounds 14: $2y$14$… 932 ms
default: $2y$12$… length 60
check ok: true, wrong: false
needsRehash at 12: false
needsRehash at 13: true
argon2id: $argon2id$v=19$m=65536,t=4,p=1$… 189 ms
bcrypt driver checking argon2id hash: This password does not use the Bcrypt algorithm.
long input: true
  • Rehashing happens by itself on login. Auth::attempt rehashes the password when the cost has changed, as the test on Authentication shows. needsRehash matters only for code that checks passwords by hand.

  • Switching algorithms throws. Hash::check refuses a hash from a different algorithm, so a crafted hash cannot downgrade the check. While migrating from bcrypt to Argon2id, HASH_VERIFY=false lets both kinds verify until every user has logged in again.

  • bcrypt ignores everything after 72 bytes. Two passwords that share their first 72 bytes match each other. BCRYPT_LIMIT=72 makes Hash::make throw Value is too long to hash instead. A max:72 validation rule on the password field gives the user a proper message, although it counts characters rather than bytes. Argon2id has no such limit.

Output, input and queries

These defences are covered on the pages for the features they protect. In summary:

  • XSS: {{ }} escapes with htmlspecialchars, {!! !!} does not, and @js encodes for a script context. Escaping does not make a URL safe. href="{{ $site }}" still renders javascript:alert(document.cookie) unchanged, so a user-supplied link needs the url validation rule, which rejects that scheme:

    <p>&lt;img src=x onerror=alert(1)&gt;</p>                      <!-- {{ $bio }} -->
    <p><img src=x onerror=alert(1)></p>                            <!-- {!! $bio !!} -->
    <a href="javascript:alert(document.cookie)">site</a>           <!-- href="{{ $site }}" -->
    <script>const bio = '<img src=x onerror=alert(1)>';</script>   <!-- @js($bio) -->

    More on Blade Templates.

  • Mass assignment: models list $fillable (or #[Fillable]). Model::preventSilentlyDiscardingAttributes() turns a dropped key into an exception (Eloquent Models).

  • SQL injection: the builder binds values. The *Raw methods and DB::raw() do not, and a column name taken from the request in orderByRaw leaked data in the run on Database and Query Builder. Allow-list column names.

  • Uploads: validate with file, mimes, max and dimensions, and store outside public/ under a generated name (File Storage and Images).

Signed URLs

A signed URL carries an HMAC-SHA256 signature of the full URL, keyed with APP_KEY. A temporary one also signs its expires timestamp. The signed middleware returns 403 for any change. Bookshelf mails review invitations as signed links, so they work without logging in:

<?php
// routes/web.php
Route::get('/invites/{book}', fn (App\Models\Book $book) => 'Invitation to review '.$book->title)
    ->name('invites.show')->middleware('signed');

// when sending the mail
$url = URL::temporarySignedRoute('invites.show', now()->plus(hours: 1), ['book' => 1]);
http://localhost:8000/invites/1?expires=1791239490&signature=…   (64 hex characters)
valid: Invitation to review Kindred 200
other book: 403
expires changed: 403
expired: 403
extra param: 403

The last line shows a common trap. A mail client or tracker that appends utm_* parameters breaks the link. A route that must tolerate them checks $request->hasValidSignatureWhileIgnoring(['utm_source', …]) itself, instead of using the middleware. A signed URL can be forwarded, so anything sensitive behind it still needs authorization.

Rate limiting

A named limiter returns one or more limits. Each limit gets its own key, so several limits on one route do not share a counter. The notes route allows 3 posts a minute and 100 a day per IP:

<?php
// app/Providers/AppServiceProvider.php, in boot()
RateLimiter::for('notes', fn (Request $request) => [
    Limit::perMinute(3)->by('m:'.$request->ip()),
    Limit::perDay(100)->by('d:'.$request->ip()),
]);

The fourth post in a minute gets a 429, with the time to wait in Retry-After (body trimmed to the message):

HTTP/1.1 429 Too Many Requests
X-RateLimit-Limit: 3
X-RateLimit-Remaining: 0
Retry-After: 56
X-RateLimit-Reset: 1791235873
Content-Type: application/json

{"message": "Too Many Attempts."}

$request->ip() is the client’s address only when trusted proxies are configured (Middleware). Behind a load balancer without that, every visitor shares one counter. Limiter stores, RateLimiter::attempt and response-based limits are on Cache and Rate Limiting.

CORS

The global HandleCors middleware answers preflight requests from config/cors.php. The framework default (used until php artisan config:publish cors copies it) allows any origin on api/* and sanctum/csrf-cookie, without credentials. That is right for a public, token-authenticated API. A cookie-authenticated SPA needs its exact origin and supports_credentials:

<?php
// config/cors.php (changed keys only)
return [
    'paths' => ['api/*', 'sanctum/csrf-cookie'],
    'allowed_origins' => [env('SPA_ORIGIN', 'https://app.bookshelf.test')],
    'supports_credentials' => true,
    'max_age' => 600,              // the default 0 sends a preflight before every request
];
$ curl -s -i -X OPTIONS -H 'Origin: https://evil.example' -H 'Access-Control-Request-Method: POST' \
    -H 'Access-Control-Request-Headers: x-xsrf-token' localhost:8000/api/tokens
HTTP/1.0 204 No Content
Access-Control-Allow-Origin: https://app.bookshelf.test
Access-Control-Allow-Credentials: true
Access-Control-Allow-Methods: POST
Access-Control-Allow-Headers: x-xsrf-token
Access-Control-Max-Age: 600

The answer to evil.example names the SPA’s origin, which does not match, so the browser blocks the request. CORS is enforced by the browser on reading the response. It does not stop the server from receiving the request, which is why request forgery and authentication checks still apply (CORS).

Security headers and CSP

Laravel sends no security headers by default. Bookshelf adds them in a global middleware that keeps any Content-Security-Policy a route has already set (Middleware). A strict CSP needs nonces on the script and style tags that @vite writes, which Vite::useCspNonce() provides (Vite and Frontend Assets):

<?php
// in the CSP middleware
Vite::useCspNonce();
return $next($request)->withHeaders([
    'Content-Security-Policy' => "script-src 'nonce-".Vite::cspNonce()."'",
]);

Cache unserialization

The cache stores values with serialize(). If APP_KEY or the cache store leaks, a crafted entry can trigger a PHP deserialization gadget chain. Laravel 13’s new config/cache.php sets 'serializable_classes' => false, so cached objects come back as __PHP_Incomplete_Class. Arrays and scalars are unaffected:

<?php
Cache::put('fee', new Money(150, 'EUR'), 60);
get_class(Cache::get('fee'));                                    // __PHP_Incomplete_Class

Cache::remember('book1', 60, fn () => Book::first());           // miss: App\Models\Book
Cache::remember('book1', 60, fn () => Book::first());           // hit:  __PHP_Incomplete_Class

// config/cache.php: allow exactly the classes the application caches
// 'serializable_classes' => [App\ValueObjects\Money::class],
serializable_classes false: __PHP_Incomplete_Class
array: {"amount":150,"currency":"EUR"}
allow-listed: App\ValueObjects\Money 150 EUR
remember model, miss: App\Models\Book, hit: __PHP_Incomplete_Class

The Cache::remember line is the trap. The first request works, because it returns the freshly built model, and every cache hit after it returns a broken object. Cache arrays (->toArray(), or IDs that are loaded again) rather than models. If models really must be cached, list every class inside the value too. With only Book allowed, a cached book came back with its loaded author relation as __PHP_Incomplete_Class, and a cached Book::limit(2)->get() came back broken as a whole, because the Eloquent Collection class was not listed.

Secrets and dependencies

  • .env stays out of Git. php artisan env:encrypt --env=staging --readable writes .env.staging.encrypted, which can be committed, with variable names in clear and values encrypted. The key is printed once. The deploy decrypts with LARAVEL_ENV_ENCRYPTION_KEY set in the environment:

    $ php artisan env:encrypt --env=staging --readable --key="$KEY"
    $ head -c 30 .env.staging.encrypted
    APP_NAME=eyJpdiI6Ii9rZTdJZ3VuW…
    $ php artisan env:decrypt --env=staging --key="$OTHER"
       ERROR  The MAC is invalid.
    $ LARAVEL_ENV_ENCRYPTION_KEY="$KEY" php artisan env:decrypt --env=staging
       INFO  Environment successfully decrypted.
  • APP_DEBUG=false in production. With debug on, error responses expose configuration values. The 429 response above, rendered with debug on, included the full stack trace and file paths.

  • composer audit checks composer.lock against the GitHub and FriendsOfPHP advisory databases. Bookshelf’s run printed "No security vulnerability advisories found." An earlier run on the MongoDB page flagged mongodb/mongodb 2.1.2 (Redis, MongoDB and Search). Run it in CI and fail the build on findings.

The OWASP Laravel Cheat Sheet lists these settings as a checklist, and the OWASP Top 10 mapping for PHP is on Web Security Fundamentals.

Where older books and tutorials are outdated

  • VerifyCsrfToken in app/Http/Middleware with a $except property (Stauffer ch. 3) became PreventRequestForgery in Laravel 13, configured with preventRequestForgery(except: …) in bootstrap/app.php. It checks Sec-Fetch-Site before the token.

  • CSRF tokens made with md5(uniqid(rand())) (Kromann ch. 14, Wiley ch. 8) are predictable. Laravel’s session token is 40 random characters, compared with hash_equals.

  • MD5 or SHA-1 password hashes (Wiley ch. 8, 15) are fast to brute-force. Hash::make uses bcrypt or Argon2id.

  • One key reused for encryption and for an HMAC, compared with == (Kromann ch. 18, 19): Crypt does authenticated encryption in one call, and signed URLs compare signatures in constant time.

  • Reversible AES_ENCRYPT() in SQL with the key in the query (Powers ch. 19) puts the key in query logs. Use the encrypted cast, which encrypts in PHP.

  • .env.example as the only way to share configuration is complemented by encrypted environment files (Laravel 9).