For a while we did what a lot of teams do when a project needs API auth: knock together a token scheme in an afternoon. Random string, a column in the users table, check it on the way in, maybe an expiry column if someone thought of it. Works fine, until the day it doesn't. For us that day showed up as a vendor breach that had nothing to do with our own code and everything to do with a decision we'd made two years earlier.
Here's what happened, and why ability scoping isn't optional on anything we ship now.
A few terms first
Ability (scope), a label on a token describing what it can do, reports:read for example. The part that matters isn't the label, it's that your code actually checks for it before doing anything sensitive, instead of assuming a valid token means "go ahead."
Revocation, killing one token without touching the rest of the account. Sounds obvious, plenty of homegrown systems can't actually do it cleanly.
Sanctum, Laravel's lightweight option for tokens going to things you control, your own SPA or app. No OAuth dance needed, you already trust both ends.
Passport, the real OAuth2 implementation, for when a third party you don't control needs credentials. Grant types, refresh rotation, the works.
Blast radius, what someone can actually do with a token once they have it. Decided the day you issue it, not the day it leaks.
What this looks like in code
Issuing a token with an actual scope on it:
// Only grant what this integration actually needs
$token = $user->createToken(
name: 'reporting-integration',
abilities: ['reports:read'],
expiresAt: now()->addDays(30),
);
return response()->json(['token' => $token->plainTextToken]);
Checking that ability where it matters:
public function export(Request $request)
{
if (! $request->user()->tokenCan('reports:read')) {
abort(403);
}
// proceed with export
}
Pulling one token without touching the rest of the account:
// Revoke exactly one device's token, nothing else on the account
$user->tokens()->where('name', 'reporting-integration')->delete();
What actually happened to us
A couple years back, a client had us hook their platform up to a third-party analytics vendor. We issued that vendor one API token, full account access, because at the time it was the only integration they had and nobody scoped it down. Got dropped into the vendor's server config and, honestly, we forgot about it. Sat there doing its job for two years.
Then the vendor got breached. Not us, them, one of their internal servers got popped, and whatever was on it went with it, our token included. Since we'd never scoped it, that token wasn't "can read some analytics." It was read every customer record, touch billing, mint new tokens.
We couldn't just revoke it and move on either, killing the token meant killing the whole integration, nothing narrower underneath to fall back to. And when someone asked "what could they actually have done with this," we didn't have a fast answer, we had to go endpoint by endpoint figuring out what that token's role could reach, on the clock.
The breach itself wasn't on us, the vendor's infrastructure was their problem. What stuck with us was that we'd left the blast radius question wide open until the worst possible time to be answering it. So we changed the default. Every external integration now gets a token scoped to exactly what it needs, with an expiry that forces someone to look at it again down the line.
Why we don't roll this ourselves anymore
Token auth has plenty of ways to quietly break that you won't notice until you're under load or under attack, timing issues on comparison, no real revocation path, tokens that live forever. We were carrying all of that ourselves, auditing it as Laravel kept moving underneath.
Handing it to Sanctum and Passport made things simpler, not more complicated. They ship alongside Laravel's own security releases, they've been through the same scrutiny as the rest of the framework, and scoping, expiry, revocation, it's just there. We didn't have to design any of it.
Scoping is the bit everyone skips, us included
The thing we see teams skip most is scoping tokens properly at the start. The excuse is always "we'll tighten it up later." Later rarely comes, because tightening it up later means auditing every consumer of that token to figure out what it's actually using, and that's not a task anyone schedules until something forces it. We were exactly that team.
Every token we issue now gets explicit abilities from the start, even for a client that only exists for one narrow integration. Costs nothing extra. Means the next leak, whenever it happens, was already capped before anyone knew there was a problem.
Picking between Sanctum and Passport
Not a taste call. Sanctum makes sense when you own both ends, your SPA, your app. Passport earns its weight when there's an actual outside party, real grant types, refresh tokens, client credentials for machine-to-machine.
Passport on a first-party SPA is over-built. Sanctum on a public third-party integration is under-built, in a way that tends to surface at the worst time. Ask who's actually holding the token on the other end, not which package you like more.
// Third-party client authenticates itself, not a user
Route::post('/oauth/token', [AccessTokenController::class, 'issueToken']);
// grant_type=client_credentials&client_id=...&client_secret=...&scope=orders:read
// Protecting a route with the granted scope
Route::middleware(['auth:api', 'scope:orders:read'])->get('/api/orders', [OrderController::class, 'index']);
Passport models an actual client with its own credentials, not just a token handed to one of your users.
What we got out of it
The payoff isn't in the initial setup. It shows up months later, when a client's laptop gets stolen and you need to kill that one device without logging out the other eleven, or a security review asks you to prove an integration can only read. With Sanctum or Passport that's a quick check. With what we had before, it's a migration and a scramble.
That's the real case for not building this yourself, not that it's faster up front, but that it still holds up months later, under pressure, when whoever's dealing with it might not be the person who set it up.
If you've got a token floating around with more access than it needs, worth twenty minutes to go check before it becomes an incident. Full version of this story, with more of the background, is over on our
Substack: https://ucodesoft.substack.com/p/sanctum-and-passport-why-we-stopped



Top comments (0)