419 Page Expired is Laravel telling you a CSRF check failed. In a video upload flow it shows up twice, for opposite reasons. On your own upload route the browser has a token and isn't sending it. On the webhook route the sender is FastPix, which has no token and never will. The first is fixed by adding a header. The second is fixed by removing the check, which immediately hands you a different problem.
TL;DR
A 419 is Laravel's CSRF middleware rejecting a request before your controller runs. On your own upload route, read the XSRF-TOKEN cookie, run it through decodeURIComponent, and send it back as the X-XSRF-TOKEN header. Skipping the decode is why a request that carries the header still fails. The webhook route has no fix of that kind, because FastPix has no session. Exclude the path in validateCsrfTokens in bootstrap/app.php, then verify FastPix-Signature against $request->getContent(). CSRF was the only protection that route had.
What a 419 error means in Laravel
Laravel returns 419 specifically from VerifyCsrfToken, which means the request reached routing and was rejected before your controller ran.
Nothing in your controller matters, the route exists, and authentication isn't involved. Something about the token is wrong: missing, stale, or impossible to supply.
| Symptom | Cause | Fix |
|---|---|---|
| 419 on POST /api/upload-url from your own page | fetch is not sending the token | Send X-XSRF-TOKEN from the cookie |
| 419 on the webhook route | FastPix cannot produce a token | Exclude the route, verify the signature |
| 419 after the tab sat open for hours | Session expired, token is stale | Refresh, or handle 419 by reloading |
What you need before you start
- A Laravel application with
routes/web.phpand the default CSRF middleware. - A FastPix account with an Access Token ID and Secret Key from activate your account.
- Browser devtools open on the Network tab, because the cookie is the evidence.
Case one: fetch is not sending the Laravel CSRF token
Routes in routes/web.php are CSRF-protected. A Blade form gets the token automatically from @csrf. A fetch call gets nothing, because you wrote it.
Laravel has already put the token in a cookie called XSRF-TOKEN, and it accepts it back as the X-XSRF-TOKEN header. The value is URL-encoded in the cookie. Decode it before sending:
function csrfToken(): string {
const cookie = document.cookie.split('; ').find((c) => c.startsWith('XSRF-TOKEN='));
return cookie ? decodeURIComponent(cookie.split('=')[1]) : '';
}
async function createUpload() {
const response = await fetch('/api/upload-url', {
method: 'POST',
headers: { 'X-XSRF-TOKEN': csrfToken() },
});
const { url } = await response.json();
return url as string;
}Skipping decodeURIComponent is the version of this bug that looks like it should work. The header is present and the request still 419s. The raw cookie value contains percent-encoded characters that don't match the session token.
The upload route itself is covered in Laravel video upload without touching php.ini. That function then goes to the uploader as endpoint. It takes a function rather than a URL string, so a fresh token-bearing request runs each time a file is picked:
<FastPixUploader endpoint={createUpload} accept="video/*" />That's the whole client side, and the uploader's own options cover layout and callbacks from there.
You can prove this end to end before committing to it. The free plan covers 10 videos and 100K streaming minutes a month with no credit card.
Case two: the FastPix webhook route returns 419
FastPix posts video.media.ready to your endpoint when encoding finishes. It's a server on the internet with no session and no cookie. There's no token for it to send, and no header you can ask it for.
This isn't a bug to fix in the request. The route has to stop expecting a token:
// bootstrap/app.php
->withMiddleware(function (Middleware $middleware) {
$middleware->validateCsrfTokens(except: [
'api/fastpix/webhook',
]);
})The path in that array is matched against the request path, and Laravel trims surrounding slashes before comparing. So api/fastpix/webhook and /api/fastpix/webhook behave the same. What does break the exclusion is a mismatched path, or a prefix without the * wildcard. The list is read from bootstrap/app.php on every request, so there's no config cache to clear here.
Excluding the webhook route makes signature verification mandatory
CSRF protection was the only thing standing between that URL and the open internet. Now anything can post to it, and a fabricated video.media.ready would mark any media ID as ready and playable.
So the signature check isn't an optional hardening step. It's the replacement for the protection you just removed.
FastPix-Signature carries a Base64-encoded HMAC-SHA256 of the request body, and the word that matters is body. Verify against $request->getContent(), the raw bytes:
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
class FastPixWebhookController extends Controller
{
public function webhook(Request $request)
{
$raw = $request->getContent();
if (! $this->signatureIsValid($raw, $request->header('FastPix-Signature'))) {
return response()->json(['message' => 'invalid signature'], 400);
}
$payload = json_decode($raw, true);
// ... handle $payload['type'] here
return response()->noContent();
}
private function signatureIsValid(string $raw, ?string $header): bool
{
$secret = config('fastpix.webhook_secret');
if (! $secret || $header === null) {
return false;
}
$expected = base64_encode(
hash_hmac('sha256', $raw, base64_decode($secret), true)
);
return hash_equals($expected, $header);
}
}The Signing Secret from the dashboard is Base64. The HMAC key is its decoded bytes, so base64_decode($secret) isn't optional. Sign with the Base64 string itself and every genuine delivery fails the check.
hash_hmac with the fourth argument true returns raw binary, which is then Base64 encoded to match the header format. Compare with hash_equals rather than ===, because it runs in constant time and doesn't leak how much of the signature matched.
Without the empty-secret guard, an unset config value becomes an empty HMAC key, and anyone who signs a body with an empty key passes the check on a route you just removed CSRF from. Fail closed.
Define the secret alongside your credentials:
// config/fastpix.php
return [
'username' => env('FASTPIX_USERNAME'),
'password' => env('FASTPIX_PASSWORD'),
'webhook_secret' => env('FASTPIX_WEBHOOK_SECRET'),
];Verifying against $request->json()->all() and re-encoding it is the second-most-common support question in this flow. The data is identical and the bytes aren't. Key order and whitespace are free to change in the round trip, so the hash never matches. The full verification path, including the Base64-decoded signing key, is in the video.media.ready webhook, and how to verify it.
Why routes/api.php is not the shortcut it looks like
Moving the upload route into routes/api.php removes the CSRF middleware and the 419 disappears. That's a reasonable choice for a genuinely stateless API consumed by a token-bearing client.
It's the wrong reflex when your uploader is a page in the same Laravel app. That route is being called by a logged-in session, from your own origin. CSRF protection is doing exactly the job it exists for, and sending one header keeps it.
Use api.php when the caller is a separate client authenticating with a bearer token. Keep web.php and send the header when the caller is your own authenticated page. The webhook is neither, which is why it gets an explicit exclusion rather than a home in api.php.
The third 419: an expired Laravel session
A page left open long enough for the session to expire will 419 on its next request. That's worth handling, because a long upload flow is exactly where it happens.
The token belongs to a session. When the session lapses, the token in the cookie no longer matches anything server-side. A request that worked an hour ago now fails. Treat a 419 in your client code as an instruction to reload, not an error to display. The reload mints a fresh session and token.
This case is the reason 419 reports are often irreproducible. The person filing the report had a tab open overnight, and you tested on a fresh page.
Get your Laravel upload route returning 200
Read XSRF-TOKEN, decode it, send it as X-XSRF-TOKEN, and exclude only the webhook path in bootstrap/app.php. Then verify FastPix-Signature against $request->getContent() so the excluded route isn't open. The free plan covers 10 videos and 100K streaming minutes a month, no credit card. Reproduce both 419 paths and close them before any of it matters.
Frequently Asked Questions (FAQs)
What does a 419 error mean in Laravel?
It means the CSRF check in VerifyCsrfToken rejected the request before your controller ran. The token was missing, did not match the session, or belonged to a session that has since expired. It is not an authentication or authorization failure, and the route itself is fine.
How do I send a CSRF token with fetch in Laravel?
Read the XSRF-TOKEN cookie, decode it with decodeURIComponent, and send it as the X-XSRF-TOKEN header. Laravel sets that cookie automatically for routes/web.php. Skipping the decode is a common cause of a 419 on a request that carries the header. The percent-encoded value does not match the session token.
Why does my FastPix webhook return 419 in Laravel?
Because FastPix has no session and cannot send a CSRF token. Exclude the webhook path in validateCsrfTokens(except: [...]) in bootstrap/app.php. Once excluded, verify the FastPix-Signature header against the raw body, since CSRF was the only thing protecting that endpoint before.
Should I move my upload route to routes/api.php to avoid 419?
Only if the caller is genuinely a separate client authenticating with a bearer token. When the uploader is a page in the same Laravel app, the request comes from a logged-in session on your own origin. CSRF protection is doing its job there, and sending one header keeps it rather than removing it.
Why does a 419 only happen sometimes?
Usually because the session expired while the tab sat open. The token in the cookie no longer matches anything server-side, so the next request fails even though the same code worked earlier. Handle a 419 response in client code by reloading the page, which mints a fresh session and token.





