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:
-
The request is a read (
GET,HEAD,OPTIONS), or the tests are running. -
The URI is in the
exceptlist. -
Origin check: the browser’s
Sec-Fetch-Siteheader issame-origin, orsame-sitewhenallowSameSiteis on. -
Token check:
_tokenin the body, or theX-CSRF-TOKENheader, or the encryptedX-XSRF-TOKENheader, equals the session’s token (compared withhash_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"}
Source: Laravel — X-CSRF-TOKEN and X-XSRF-TOKEN
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-originfrom any client, socurlpasses without a token. This is safe, because the attack needs the victim’s own browser, and a page cannot setSec-Fetch-*headers from script. Browsers send the header only to secure origins (HTTPS orlocalhost), 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();
Source: Laravel — Origin Verification and Excluding URIs
$ 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
Source: Laravel — Origin Verification
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
Source: Laravel — Using the Encrypter
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}
Source: Laravel — Using the Encrypter
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
Source: Laravel — Hashing Configuration
-
Rehashing happens by itself on login.
Auth::attemptrehashes the password when the cost has changed, as the test on Authentication shows.needsRehashmatters only for code that checks passwords by hand. -
Switching algorithms throws.
Hash::checkrefuses a hash from a different algorithm, so a crafted hash cannot downgrade the check. While migrating from bcrypt to Argon2id,HASH_VERIFY=falselets 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=72makesHash::makethrowValue is too long to hashinstead. Amax:72validation 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 withhtmlspecialchars,{!! !!}does not, and@jsencodes for a script context. Escaping does not make a URL safe.href="{{ $site }}"still rendersjavascript:alert(document.cookie)unchanged, so a user-supplied link needs theurlvalidation rule, which rejects that scheme:<p><img src=x onerror=alert(1)></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) -->Source: Laravel — Displaying Unescaped Data and url rule
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
*Rawmethods andDB::raw()do not, and a column name taken from the request inorderByRawleaked data in the run on Database and Query Builder. Allow-list column names. -
Uploads: validate with
file,mimes,maxanddimensions, and store outsidepublic/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
Source: Laravel — Signed URLs
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()),
]);
Source: Laravel — Multiple Rate Limits
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
];
Source: Laravel — CORS and Sanctum CORS and Cookies
$ 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
Source: Laravel — CORS
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
-
.envstays out of Git.php artisan env:encrypt --env=staging --readablewrites.env.staging.encrypted, which can be committed, with variable names in clear and values encrypted. The key is printed once. The deploy decrypts withLARAVEL_ENV_ENCRYPTION_KEYset 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=falsein 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 auditcheckscomposer.lockagainst the GitHub and FriendsOfPHP advisory databases. Bookshelf’s run printed "No security vulnerability advisories found." An earlier run on the MongoDB page flaggedmongodb/mongodb2.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
-
VerifyCsrfTokeninapp/Http/Middlewarewith a$exceptproperty (Stauffer ch. 3) becamePreventRequestForgeryin Laravel 13, configured withpreventRequestForgery(except: …)inbootstrap/app.php. It checksSec-Fetch-Sitebefore 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 withhash_equals. -
MD5 or SHA-1 password hashes (Wiley ch. 8, 15) are fast to brute-force.
Hash::makeuses bcrypt or Argon2id. -
One key reused for encryption and for an HMAC, compared with
==(Kromann ch. 18, 19):Cryptdoes 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 theencryptedcast, which encrypts in PHP. -
.env.exampleas the only way to share configuration is complemented by encrypted environment files (Laravel 9).