6 min read
Auth & Policies
Sanctum vs Passport, authorization logic that lives in one Policy instead of scattered controller checks, and the difference between authenticated and authorized.
Principles
Sanctum for first-party SPAs and mobile apps, Passport for third-party OAuth
Reaching for Passport's full OAuth2 server when you just need a cookie-based SPA session, or a personal-access token for a mobile app, adds ceremony that buys nothing. Sanctum covers both of those directly.
Authorization logic belongs in a Policy, not scattered in controllers
`$this->authorize('update', $post)` reads the same everywhere and is testable in isolation. An inline ownership check in a controller is a rule nobody else will find when it needs to change.
Every mutating endpoint needs an explicit authorization check
A route being reachable only by an authenticated user is not the same as that user being allowed to do this specific thing to this specific resource. Missing the second check is the single most common API vulnerability.
Gates for actions without a model, Policies for actions on one
`Gate::define('access-admin-panel', ...)` for a yes/no capability, a Policy method for 'can this user edit this post.' Using a Gate for the second forces manual model-ownership checks the Policy would have given you for free.
Policy responses should say why, not just whether
`Response::deny('...')` with a message turns a bare 403 into an actionable API error instead of a mystery.
Patterns
What goes wrong, what to do instead, and why the difference matters.
Full OAuth2 server for a first-party SPA
Avoid
// Passport installed for a first-party React SPA that just needs
// to be logged in — full OAuth2 clients, token refresh flow,
// and a database of grants for an app with one client: itself.OAuth2's client/grant model solves a third-party-access problem the app doesn't have, at the cost of real complexity.
Prefer
// Sanctum — SPA authenticates via a same-domain cookie, no OAuth
// dance, CSRF-protected out of the box.
Route::middleware('auth:sanctum')->get('/api/user', fn (Request $r) => $r->user());The auth mechanism matches the actual problem: one first-party client, session-based, minimal setup.
Authorization logic inline in the controller
Avoid
public function update(Request $request, Post $post)
{
if ($post->author_id !== $request->user()->id) {
abort(403);
}
// ...
}
// The exact same check gets re-implemented, slightly differently,
// in destroy(), in the API controller, and in the admin panel.The rule for 'who can edit a post' exists in as many copies as there are places that need to check it, and they will drift.
Prefer
class PostPolicy
{
public function update(User $user, Post $post): bool
{
return $user->id === $post->author_id;
}
}
public function update(Request $request, Post $post)
{
$this->authorize('update', $post);
// ...
}One rule, one place. Every caller — web controller, API controller, admin action — authorizes through the same Policy method.
Route-level auth mistaken for object-level authorization
Avoid
Route::middleware('auth:sanctum')->put('/posts/{post}', [PostController::class, 'update']);
public function update(Request $request, Post $post)
{
// Authenticated, yes. Authorized to edit THIS post? Never checked.
$post->update($request->validated());
}Any logged-in user can edit any post by ID — the middleware only confirmed they're someone, not that they're allowed to do this.
Prefer
public function update(Request $request, Post $post)
{
$this->authorize('update', $post); // object-level check, every time
$post->update($request->validated());
}Authentication confirms who's asking; authorization — checked explicitly, per resource — confirms they're allowed to do this specific thing.
Review checklist
What I look for when reviewing a pull request that touches this area.
- Sanctum is used for first-party SPA/mobile auth; Passport is reserved for genuine third-party OAuth2 needs.
- Every model with per-user permissions has a Policy, not inline authorization checks in controllers.
- Every mutating endpoint calls `authorize()` (or an equivalent Policy check) on the specific resource, not just relying on route middleware.
- Gates are used for model-less capabilities; Policies are used for anything checked against a specific model instance.
- Policy denials return a message explaining why, not a bare 403.
- Authorization logic has its own tests independent of the controllers that call it.
Take the skill
Everything above, generated into a skill file your agent can load. Copy it, or download it and commit it to your repo.
.claude/skills/laravel-auth-and-policies/SKILL.mdDrop into .claude/skills/ or .cursor/skills/ — the frontmatter drives when the skill loads.
---
name: laravel-auth-and-policies
description: >-
Laravel authentication and authorization: choosing Sanctum vs Passport,
Policy-based object-level authorization, Gates vs Policies, and explicit
per-endpoint authorization checks. Use when adding auth or reviewing
access control.
argument-hint: "[model or endpoint]"
license: MIT
metadata:
author: John Felix Lim
version: "1.0.0"
---
# Auth & Policies
Apply when adding auth to an app or reviewing access control on an endpoint. Confirm every mutating action checks authorization against the specific resource, not just that the request is authenticated.
## Rules
1. **Sanctum for first-party SPAs and mobile apps, Passport for third-party OAuth** — Reaching for Passport's full OAuth2 server when you just need a cookie-based SPA session, or a personal-access token for a mobile app, adds ceremony that buys nothing. Sanctum covers both of those directly.
2. **Authorization logic belongs in a Policy, not scattered in controllers** — `$this->authorize('update', $post)` reads the same everywhere and is testable in isolation. An inline ownership check in a controller is a rule nobody else will find when it needs to change.
3. **Every mutating endpoint needs an explicit authorization check** — A route being reachable only by an authenticated user is not the same as that user being allowed to do this specific thing to this specific resource. Missing the second check is the single most common API vulnerability.
4. **Gates for actions without a model, Policies for actions on one** — `Gate::define('access-admin-panel', ...)` for a yes/no capability, a Policy method for 'can this user edit this post.' Using a Gate for the second forces manual model-ownership checks the Policy would have given you for free.
5. **Policy responses should say why, not just whether** — `Response::deny('...')` with a message turns a bare 403 into an actionable API error instead of a mystery.
## Patterns
### Full OAuth2 server for a first-party SPA
**Avoid** — OAuth2's client/grant model solves a third-party-access problem the app doesn't have, at the cost of real complexity.
```php
// Passport installed for a first-party React SPA that just needs
// to be logged in — full OAuth2 clients, token refresh flow,
// and a database of grants for an app with one client: itself.
```
**Prefer** — The auth mechanism matches the actual problem: one first-party client, session-based, minimal setup.
```php
// Sanctum — SPA authenticates via a same-domain cookie, no OAuth
// dance, CSRF-protected out of the box.
Route::middleware('auth:sanctum')->get('/api/user', fn (Request $r) => $r->user());
```
### Authorization logic inline in the controller
**Avoid** — The rule for 'who can edit a post' exists in as many copies as there are places that need to check it, and they will drift.
```php
public function update(Request $request, Post $post)
{
if ($post->author_id !== $request->user()->id) {
abort(403);
}
// ...
}
// The exact same check gets re-implemented, slightly differently,
// in destroy(), in the API controller, and in the admin panel.
```
**Prefer** — One rule, one place. Every caller — web controller, API controller, admin action — authorizes through the same Policy method.
```php
class PostPolicy
{
public function update(User $user, Post $post): bool
{
return $user->id === $post->author_id;
}
}
public function update(Request $request, Post $post)
{
$this->authorize('update', $post);
// ...
}
```
### Route-level auth mistaken for object-level authorization
**Avoid** — Any logged-in user can edit any post by ID — the middleware only confirmed they're someone, not that they're allowed to do this.
```php
Route::middleware('auth:sanctum')->put('/posts/{post}', [PostController::class, 'update']);
public function update(Request $request, Post $post)
{
// Authenticated, yes. Authorized to edit THIS post? Never checked.
$post->update($request->validated());
}
```
**Prefer** — Authentication confirms who's asking; authorization — checked explicitly, per resource — confirms they're allowed to do this specific thing.
```php
public function update(Request $request, Post $post)
{
$this->authorize('update', $post); // object-level check, every time
$post->update($request->validated());
}
```
## Review checklist
- [ ] Sanctum is used for first-party SPA/mobile auth; Passport is reserved for genuine third-party OAuth2 needs.
- [ ] Every model with per-user permissions has a Policy, not inline authorization checks in controllers.
- [ ] Every mutating endpoint calls `authorize()` (or an equivalent Policy check) on the specific resource, not just relying on route middleware.
- [ ] Gates are used for model-less capabilities; Policies are used for anything checked against a specific model instance.
- [ ] Policy denials return a message explaining why, not a bare 403.
- [ ] Authorization logic has its own tests independent of the controllers that call it.
---
From the John Felix Lim Frontend Engineering Playbook — https://github.com/JohnFelixLim