start-vibing-stacks 2.8.0 → 2.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,70 +1,279 @@
1
1
  ---
2
2
  name: api-design
3
- version: 1.0.0
3
+ version: 2.0.0
4
+ description: PHP/Laravel REST API design contract — endpoint shape, pagination,
5
+ filtering, sorting, dates, idempotency, error response envelope. The contract
6
+ the React+Axios SPA expects on the wire. Use when designing any new API
7
+ surface. Pairs with `laravel-api-architecture` (the implementation pipeline)
8
+ and `axios-laravel-api` (the consumer).
4
9
  ---
5
10
 
6
- # PHP API Design Standards
11
+ # Laravel REST API Design Contract
12
+
13
+ **ALWAYS invoke when designing or naming endpoints, response shapes, pagination,
14
+ or filters. This is the contract — the implementation pipeline is in
15
+ `laravel-api-architecture`.**
7
16
 
8
17
  ## RESTful Principles
9
18
 
10
- ### Endpoint Design
19
+ ### Endpoint Naming
11
20
 
12
21
  ```
13
- GET /api/users # List (paginated)
14
- GET /api/users/{id} # Show
15
- POST /api/users # Create
16
- PUT /api/users/{id} # Full update
17
- PATCH /api/users/{id} # Partial update
18
- DELETE /api/users/{id} # Delete
22
+ GET /api/users # List (paginated)
23
+ GET /api/users/{id} # Show
24
+ POST /api/users # Create
25
+ PUT /api/users/{id} # Replace (full update)
26
+ PATCH /api/users/{id} # Partial update
27
+ DELETE /api/users/{id} # Delete
28
+ ```
29
+
30
+ ### Action Endpoints (NOT generic PATCH)
19
31
 
20
- # Business action endpoints (NOT generic PATCH)
32
+ ```
21
33
  POST /api/leads/{id}/reset-attempts
22
34
  POST /api/domains/{id}/refresh-list
35
+ POST /api/orders/{id}/cancel
23
36
  POST /api/reports/{id}/regenerate
24
37
  ```
25
38
 
26
- **Rule:** Specific business actions get dedicated `POST` endpoints, not generic `PUT/PATCH`.
39
+ **Rule:** Specific business actions get dedicated `POST` endpoints. Don't
40
+ overload `PATCH` with side-effects.
41
+
42
+ ## Response Envelope
43
+
44
+ ### Success (single resource)
45
+
46
+ Use Laravel's `JsonResource` directly — Laravel wraps it automatically:
47
+
48
+ ```json
49
+ {
50
+ "data": {
51
+ "id": "9b8c...",
52
+ "name": "Order #1234",
53
+ "status": "paid",
54
+ "created_at": "2026-05-13T12:34:56-03:00"
55
+ }
56
+ }
57
+ ```
58
+
59
+ ### Success (collection / paginated)
60
+
61
+ ```json
62
+ {
63
+ "data": [ { "id": "..." }, { "id": "..." } ],
64
+ "links": {
65
+ "first": "https://api.example.com/api/orders?page=1",
66
+ "last": "https://api.example.com/api/orders?page=4",
67
+ "prev": null,
68
+ "next": "https://api.example.com/api/orders?page=2"
69
+ },
70
+ "meta": {
71
+ "current_page": 1,
72
+ "from": 1,
73
+ "to": 25,
74
+ "last_page": 4,
75
+ "per_page": 25,
76
+ "total": 92,
77
+ "path": "https://api.example.com/api/orders"
78
+ }
79
+ }
80
+ ```
81
+
82
+ **Rule:** Get this for free with Laravel's `->paginate($perPage)`. Don't
83
+ hand-roll pagination.
84
+
85
+ ### Error envelope
86
+
87
+ Laravel returns these natively — match them:
88
+
89
+ ```json
90
+ // 401 Unauthenticated
91
+ { "message": "Unauthenticated." }
92
+
93
+ // 403 Forbidden (Policy denied)
94
+ { "message": "This action is unauthorized." }
95
+
96
+ // 404 Not Found
97
+ { "message": "No query results for model [App\\Models\\Order] 123." }
98
+
99
+ // 419 CSRF Token Mismatch
100
+ { "message": "CSRF token mismatch." }
101
+
102
+ // 422 Validation
103
+ {
104
+ "message": "The given data was invalid.",
105
+ "errors": {
106
+ "email": ["The email field is required."],
107
+ "password":["The password must be at least 8 characters."]
108
+ }
109
+ }
110
+
111
+ // 429 Too Many Requests (also returns header Retry-After)
112
+ { "message": "Too Many Requests" }
113
+
114
+ // 500
115
+ { "message": "Server Error" }
116
+ ```
117
+
118
+ **Rule:** Don't reinvent. The frontend's Axios interceptor (see
119
+ `axios-laravel-api`) is built around exactly these shapes. Adding a custom
120
+ envelope (`{success, data, errors}`) requires updating BOTH layers — keep
121
+ Laravel defaults unless you have a hard reason not to.
122
+
123
+ ## Pagination Contract
124
+
125
+ ```php
126
+ // Controller
127
+ public function index(IndexOrderRequest $request): AnonymousResourceCollection
128
+ {
129
+ $perPage = (int) ($request->validated('per_page') ?? 25);
130
+ $perPage = min($perPage, 100); // hard cap
131
+ return OrderResource::collection(
132
+ $this->orders->listFor($request->user(), $request->validated())->paginate($perPage)
133
+ );
134
+ }
135
+ ```
136
+
137
+ ```php
138
+ // FormRequest
139
+ 'page' => ['nullable', 'integer', 'min:1'],
140
+ 'per_page' => ['nullable', 'integer', 'min:1', 'max:100'],
141
+ ```
142
+
143
+ **Rules:**
144
+ - Default `per_page = 25`. Hard cap = 100.
145
+ - Always validate `page` and `per_page` to prevent abuse.
146
+ - Use `->paginate()` for offset pagination (default), `->cursorPaginate()`
147
+ for high-volume cursors (>100k rows, infinite scroll).
148
+
149
+ ## Filter & Sort Contract
150
+
151
+ ```
152
+ GET /api/orders?status=paid&q=acme&sort=-created_at&page=2&per_page=25
153
+ ```
154
+
155
+ ```php
156
+ // FormRequest
157
+ 'status' => ['nullable', 'string', 'in:pending,paid,shipped,cancelled'],
158
+ 'q' => ['nullable', 'string', 'max:255'],
159
+ 'sort' => ['nullable', 'string', 'in:created_at,-created_at,total,-total'],
160
+ 'page' => ['nullable', 'integer', 'min:1'],
161
+ 'per_page' => ['nullable', 'integer', 'min:1', 'max:100'],
162
+ ```
163
+
164
+ ```php
165
+ // Service
166
+ if ($status = $filters['status'] ?? null) $query->where('status', $status);
167
+ if ($q = $filters['q'] ?? null) $query->where('reference', 'like', "%{$q}%");
168
+
169
+ if ($sort = $filters['sort'] ?? null) {
170
+ $direction = str_starts_with($sort, '-') ? 'desc' : 'asc';
171
+ $column = ltrim($sort, '-');
172
+ $query->orderBy($column, $direction);
173
+ }
174
+ ```
175
+
176
+ **Rules:**
177
+ - Sort syntax: `sort=field` (asc), `sort=-field` (desc). Allowed columns
178
+ whitelisted in FormRequest.
179
+ - Filter values whitelisted via `in:...` in FormRequest — never trust
180
+ enum-like params.
181
+ - `q` (free-text search) is `LIKE`/`ILIKE`. For real search, use scout +
182
+ Meilisearch / Typesense.
27
183
 
28
184
  ## Date Handling
29
185
 
30
186
  ```
31
- Client → API: ISO 8601 (UTC or with timezone offset)
32
- Database: UTC always
33
- API → Client: UTC, converted to user timezone in Resource/Response only
187
+ Client → API: ISO 8601 (UTC or with timezone offset)
188
+ Database: UTC always
189
+ API → Client: ISO 8601, converted to caller's timezone in Resource only
190
+ ```
191
+
192
+ ```php
193
+ // app/Traits/FormatsDatesForApi.php
194
+ trait FormatsDatesForApi
195
+ {
196
+ protected function formatDateTime(?Carbon $date, Request $request): ?string
197
+ {
198
+ if (! $date) return null;
199
+ $tz = $request->header('X-Timezone', $request->user()?->timezone ?? 'UTC');
200
+ return $date->copy()->setTimezone($tz)->toISOString();
201
+ }
202
+ }
34
203
  ```
35
204
 
36
205
  **Rules:**
37
- - Backend/DB stores **UTC always**
38
- - Timezone conversion **only** in API response layer
39
- - Use trait/helper for consistent formatting
40
- - Accept timezone via header (`X-Timezone`) or user profile
206
+ - Backend / DB stores **UTC always**.
207
+ - Timezone conversion **only** in API Resource layer.
208
+ - Accept timezone via `X-Timezone` header or fall back to user profile.
209
+
210
+ ## Idempotency for Unsafe Methods
211
+
212
+ For external integrations (webhooks, payment endpoints), accept an
213
+ `Idempotency-Key` header and de-dupe:
214
+
215
+ ```php
216
+ public function store(StoreLeadRequest $request): JsonResource
217
+ {
218
+ $key = $request->header('Idempotency-Key');
219
+ if ($key && $existing = Lead::where('idempotency_key', $key)->first()) {
220
+ return LeadResource::make($existing);
221
+ }
222
+ $lead = $this->leads->create([...$request->validated(), 'idempotency_key' => $key]);
223
+ return LeadResource::make($lead);
224
+ }
225
+ ```
226
+
227
+ **Rule:** All public webhook receivers MUST be idempotent (assume retries).
228
+
229
+ ## Authentication Contract (Sanctum SPA + Optional Token)
230
+
231
+ | Caller | Auth | Header / Cookie |
232
+ |--------|------|------------------|
233
+ | Same-origin React SPA | Session cookie + CSRF | `XSRF-TOKEN` cookie → `X-XSRF-TOKEN` header |
234
+ | Cross-origin React SPA | Session cookie + CSRF (CORS w/ credentials) | Same |
235
+ | Mobile / 3rd-party | Personal access token | `Authorization: Bearer <token>` |
236
+
237
+ `auth:sanctum` accepts BOTH transparently. See `axios-laravel-api` for the
238
+ Axios setup and `api-security` for token issuing/scopes.
41
239
 
42
- ## Authorization Patterns
240
+ ## Authorization Pattern
43
241
 
44
- ### User-Scoped Resources
242
+ ### User-Scoped Resources (default)
243
+
244
+ Every collection endpoint MUST scope by user (admins may see everything):
45
245
 
46
246
  ```php
47
- // Every list endpoint must scope by user (unless admin)
48
- public function index(): JsonResponse
247
+ // In the Service (NOT controller, NOT resource)
248
+ public function listFor(User $user, array $filters): Builder
49
249
  {
50
- $query = Resource::query();
51
-
52
- if (!auth()->user()->isAdmin()) {
53
- $query->where('user_id', auth()->id());
250
+ $query = Order::query();
251
+ if (! $user->isAdmin()) {
252
+ $query->where('user_id', $user->id);
54
253
  }
55
-
56
- return ResourceCollection::make($query->paginate());
254
+ // ...
255
+ return $query;
57
256
  }
58
257
  ```
59
258
 
60
- **Rule:** Never return unscoped queries. Always filter by authenticated user.
259
+ ### Per-Resource via Policy
260
+
261
+ ```php
262
+ public function show(Order $order): OrderResource
263
+ {
264
+ $this->authorize('view', $order);
265
+ return OrderResource::make($order);
266
+ }
267
+ ```
268
+
269
+ **Rule:** Never return unscoped queries from a Service. Scoping = security.
61
270
 
62
271
  ## Caching Strategy
63
272
 
64
273
  ```php
65
274
  // User-specific cache keys
66
275
  $key = "domains:user:{$userId}";
67
- $ttl = 900; // 15 minutes default
276
+ $ttl = now()->addMinutes(15);
68
277
 
69
278
  $data = Cache::remember($key, $ttl, fn () => $query->get());
70
279
 
@@ -73,25 +282,21 @@ Cache::forget("domains:user:{$userId}");
73
282
  ```
74
283
 
75
284
  **Rules:**
76
- - Cache keys MUST include user ID for isolation
77
- - Default TTL: 15 minutes
78
- - Invalidate cache on any write operation
79
- - Use Redis for user-specific, frequently accessed data
285
+ - Cache keys MUST include user ID for isolation (avoid one user seeing another's data).
286
+ - Default TTL: 15 minutes.
287
+ - Invalidate on any write operation (in the Service that performed the write).
288
+ - Use Redis for user-specific, frequently accessed data.
289
+ - For collections that mutate constantly, prefer ETag/conditional GET over cache.
80
290
 
81
291
  ## Job & Queue Patterns
82
292
 
83
293
  ### Idempotency
84
294
 
85
295
  ```php
86
- // Check state BEFORE processing
87
296
  public function handle(): void
88
297
  {
89
- if ($this->record->already_processed) {
90
- return; // Skip — idempotent
91
- }
92
-
93
- // Process...
94
-
298
+ if ($this->record->already_processed) return; // skip — idempotent
299
+ $this->process();
95
300
  $this->record->update(['already_processed' => true]);
96
301
  }
97
302
  ```
@@ -99,7 +304,6 @@ public function handle(): void
99
304
  ### Batch Processing
100
305
 
101
306
  ```php
102
- // Process in chunks for memory control
103
307
  Lead::query()
104
308
  ->where('status', 'pending')
105
309
  ->chunkById(100, function ($leads) {
@@ -110,10 +314,9 @@ Lead::query()
110
314
  ```
111
315
 
112
316
  **Rules:**
113
- - Jobs MUST be idempotent (safe to retry)
114
- - Use unique keys for external API calls
115
- - Reset jobs: set status to `pending` + reset counters
116
- - Batch/chunk for high-volume data
317
+ - Jobs MUST be idempotent (safe to retry).
318
+ - Use unique keys for external API calls.
319
+ - Batch / chunk for high-volume data.
117
320
 
118
321
  ## Data Integrity
119
322
 
@@ -136,6 +339,37 @@ protected $casts = [
136
339
 
137
340
  Before sending data to external partners (Google, Meta, etc.):
138
341
  1. Validate data status
139
- 2. Filter bots/invalid entries
342
+ 2. Filter bots / invalid entries
140
343
  3. Apply quality thresholds
141
344
  4. Use unique keys to prevent duplicates
345
+
346
+ ## Versioning (when you outgrow `/api`)
347
+
348
+ ```
349
+ /api/v1/orders # current
350
+ /api/v2/orders # breaking changes
351
+ ```
352
+
353
+ **Rule:** Add a version prefix only when breaking changes are imminent. Don't
354
+ preemptively version every endpoint.
355
+
356
+ ## Checklist — Designing Any New Endpoint
357
+
358
+ - [ ] Verb + URL match REST conventions (or it's an explicit action endpoint)
359
+ - [ ] Behind `auth:sanctum` (and `throttle:api`) by default
360
+ - [ ] FormRequest with `rules()` + `authorize()` (Policy)
361
+ - [ ] Service does the work; Controller just orchestrates
362
+ - [ ] Returns `JsonResource` / `JsonResource::collection`
363
+ - [ ] Pagination uses `->paginate()` returning `data + meta + links`
364
+ - [ ] Dates formatted via `FormatsDatesForApi`
365
+ - [ ] Sensitive fields hidden (`$hidden` on model + whitelist in Resource)
366
+ - [ ] PHPUnit feature test for happy + 422 + 403 + 401 cases
367
+
368
+ ## See Also
369
+
370
+ - `laravel-api-architecture` — the full Controller→Service→Resource pipeline
371
+ - `axios-laravel-api` — frontend client + CSRF/cookie + interceptors
372
+ - `react-api-standards` — page contract that consumes these endpoints
373
+ - `api-security` — Sanctum config, CORS, rate limiting, token abilities
374
+ - `external-api-patterns` — consuming OTHER APIs from your Laravel app
375
+ - `openapi-design` — describing the contract in OpenAPI 3.1
@@ -1,58 +1,124 @@
1
1
  ---
2
2
  name: api-security
3
- version: 1.0.0
3
+ version: 2.0.0
4
+ description: Laravel 12 + Sanctum + Octane API hardening — cookie-based SPA auth
5
+ by default, token auth for mobile/3rd-party, CORS/CSRF/HSTS/CSP, rate limiting,
6
+ brute-force protection, audit logging, encryption at rest. Use when building
7
+ any auth endpoint, public API, or handling user input. Pairs with
8
+ `laravel-api-architecture` and `axios-laravel-api`.
4
9
  ---
5
10
 
6
- # API Security — NSA-Level Hardening for Laravel + Octane
11
+ # API Security — NSA-Level Hardening for Laravel 12 + Octane
7
12
 
8
13
  **ALWAYS invoke when building APIs, auth endpoints, or handling user input.**
9
14
 
10
15
  ## Security Layers
11
16
 
12
17
  ```
13
- Internet → Cloudflare WAF → Rate Limiting → CORS → Auth Middleware
14
- Input Validation Business Logic Output SanitizationResponse
18
+ Internet → CDN/WAF → Rate Limiter → CORS → Auth Middleware
19
+ CSRF (cookie clients)FormRequest validationPolicy
20
+ → Service business logic → Resource output sanitization → JSON
15
21
 
16
22
  Every layer is a wall. Assume the previous one failed.
17
23
  ```
18
24
 
19
- ## 1. Authentication (Sanctum + Octane)
25
+ ## 1. Authentication
20
26
 
21
- ### Token-Based (API)
27
+ ### Choose ONE per client type
28
+
29
+ | Client | Mode | Why |
30
+ |--------|------|-----|
31
+ | **Same / cross-origin React SPA** | **Sanctum SPA (cookie)** | XSS-safe HttpOnly cookie; no token storage in JS |
32
+ | Mobile app | Sanctum personal access token | No browser cookie context |
33
+ | 3rd-party server-to-server | Sanctum token (scoped abilities) | Long-lived; per-key access |
34
+
35
+ ### A. Sanctum SPA (Cookie) — DEFAULT for React
22
36
 
23
37
  ```php
38
+ // bootstrap/app.php
39
+ ->withMiddleware(function (Middleware $middleware) {
40
+ $middleware->statefulApi(); // accepts session cookie on /api/*
41
+ })
42
+
24
43
  // config/sanctum.php
25
- 'expiration' => 60 * 24, // 24h token expiry (MANDATORY)
26
- 'token_prefix' => 'flk_', // Prefix for easy log scanning
44
+ 'stateful' => explode(',', env('SANCTUM_STATEFUL_DOMAINS',
45
+ 'localhost,localhost:5173,127.0.0.1,127.0.0.1:8000'
46
+ )),
47
+ 'guard' => ['web'],
48
+
49
+ // config/session.php — production
50
+ 'driver' => 'cookie',
51
+ 'http_only' => true, // MANDATORY: blocks JS access
52
+ 'secure' => true, // MANDATORY in HTTPS prod
53
+ 'same_site' => 'lax', // 'none' only if cross-origin AND HTTPS
54
+ 'domain' => '.example.com', // shared across subdomains
55
+
56
+ // config/cors.php
57
+ 'paths' => ['api/*', 'sanctum/csrf-cookie', 'login', 'logout'],
58
+ 'allowed_origins' => [env('FRONTEND_URL')],
59
+ 'supports_credentials' => true, // MANDATORY for cookie auth
60
+ ```
61
+
62
+ ```php
63
+ // routes/web.php — login on web so session middleware runs
64
+ Route::post('/login', [AuthController::class, 'login'])->middleware('throttle:auth');
65
+ Route::post('/logout', [AuthController::class, 'logout'])->middleware('auth:sanctum');
66
+
67
+ // routes/api.php — protected JSON endpoints
68
+ Route::middleware(['auth:sanctum', 'throttle:api'])->group(function () {
69
+ Route::apiResource('orders', OrderController::class);
70
+ });
71
+ ```
72
+
73
+ ```php
74
+ // AuthController::login (regenerate session = anti-fixation)
75
+ if (! Auth::attempt($request->validated(), remember: true)) {
76
+ return response()->json(['message' => 'Invalid credentials'], 422);
77
+ }
78
+ $request->session()->regenerate();
79
+ return response()->json(['data' => UserResource::make($request->user())]);
80
+ ```
81
+
82
+ **Rules:**
83
+ - `http_only: true` is non-negotiable — XSS cannot read the session cookie.
84
+ - `secure: true` in any HTTPS environment (so dev = false, prod = true).
85
+ - `SameSite=Lax` for same-origin SPAs. `SameSite=None; Secure` for cross-origin.
86
+ - Always `regenerate()` after login.
87
+ - Frontend must set `withCredentials: true` and `withXSRFToken: true` (Axios).
88
+ See `axios-laravel-api`.
89
+
90
+ ### B. Sanctum Token (Mobile / 3rd-Party)
91
+
92
+ ```php
93
+ // config/sanctum.php
94
+ 'expiration' => 60 * 24, // 24h token expiry (MANDATORY for tokens)
95
+ 'token_prefix' => 'app_', // prefix for log scanning + secret-scanner tools
27
96
 
28
97
  // Issue token with abilities (least privilege)
29
98
  $token = $user->createToken('api-client', [
30
99
  'read:leads',
31
100
  'write:leads',
32
- // NOT '*' — never wildcard abilities
101
+ // NEVER '*' — no wildcard abilities
33
102
  ])->plainTextToken;
34
103
 
35
- // Middleware: check specific ability
104
+ // Per-route ability check
36
105
  Route::middleware(['auth:sanctum', 'ability:read:leads'])->group(function () {
37
106
  Route::get('/api/v1/leads', [LeadController::class, 'index']);
38
107
  });
39
108
  ```
40
109
 
41
- ### Token Rotation
110
+ ### Token Rotation (sensitive actions)
42
111
 
43
112
  ```php
44
- // Rotate on every sensitive action
45
- public function changePassword(Request $request): JsonResponse
113
+ public function changePassword(ChangePasswordRequest $request): JsonResponse
46
114
  {
47
115
  // ... change password logic
48
116
 
49
- // Revoke ALL tokens (force re-login everywhere)
50
- $request->user()->tokens()->delete();
51
-
52
- // Issue fresh token
117
+ $request->user()->tokens()->delete(); // revoke all
53
118
  $newToken = $request->user()->createToken('session', ['*'])->plainTextToken;
54
119
 
55
- return $this->success(['token' => $newToken]);
120
+ return response()->json(['token' => $newToken]); // token clients
121
+ // Cookie clients: also call $request->session()->regenerate()
56
122
  }
57
123
  ```
58
124
 
@@ -64,7 +130,6 @@ use Laravel\Octane\Facades\Octane;
64
130
 
65
131
  public function boot(): void
66
132
  {
67
- // MANDATORY: flush auth state between requests
68
133
  Octane::prepare(function ($sandbox) {
69
134
  $sandbox->forgetScopedInstances();
70
135
  $sandbox->flushDatabaseConnections();
@@ -184,27 +249,33 @@ Route::middleware('throttle:sensitive')->group(function () {
184
249
  });
185
250
  ```
186
251
 
187
- ## 4. CORS (Restrictive)
252
+ ## 4. CORS (Restrictive — credentials-aware)
188
253
 
189
254
  ```php
190
255
  // config/cors.php
191
256
  return [
192
- 'paths' => ['api/*'],
193
- 'allowed_methods' => ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'],
257
+ 'paths' => ['api/*', 'sanctum/csrf-cookie', 'login', 'logout'],
258
+ 'allowed_methods' => ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
194
259
  'allowed_origins' => [
195
- env('APP_URL'), // Only your domain
196
- // NOT '*' — NEVER wildcard in production
260
+ env('FRONTEND_URL', env('APP_URL')), // SPECIFIC origin, never '*'
261
+ ],
262
+ 'allowed_origins_patterns' => [
263
+ // optional: '#^https://([a-z0-9-]+\.)?example\.com$#'
197
264
  ],
198
265
  'allowed_headers' => [
199
266
  'Content-Type', 'Authorization', 'X-Requested-With',
200
- 'X-Timezone', 'Accept', 'X-CSRF-TOKEN',
267
+ 'X-Timezone', 'Accept', 'X-XSRF-TOKEN', 'X-CSRF-TOKEN',
201
268
  ],
202
269
  'exposed_headers' => ['X-Request-ID', 'Retry-After'],
203
- 'max_age' => 86400, // 24h preflight cache
204
- 'supports_credentials' => true,
270
+ 'max_age' => 86400, // 24h preflight cache
271
+ 'supports_credentials' => true, // MANDATORY for cookie auth
205
272
  ];
206
273
  ```
207
274
 
275
+ **Critical:** `supports_credentials: true` is INCOMPATIBLE with
276
+ `allowed_origins: ['*']`. Browsers will reject the response. Always list
277
+ specific origins. For multiple subdomains, use `allowed_origins_patterns`.
278
+
208
279
  ## 5. Security Headers Middleware
209
280
 
210
281
  ```php
@@ -402,35 +473,43 @@ class RequestId
402
473
 
403
474
  ## Security Checklist — Before Deploy
404
475
 
405
- - [ ] All endpoints have auth middleware
406
- - [ ] All input validated via FormRequest
407
- - [ ] Rate limiting on auth + sensitive endpoints
408
- - [ ] CORS restricted to your domain only
409
- - [ ] Security headers middleware active
476
+ - [ ] `statefulApi()` middleware enabled in `bootstrap/app.php`
477
+ - [ ] `SANCTUM_STATEFUL_DOMAINS` lists exact frontend hostnames
478
+ - [ ] `config/session.php`: `http_only=true`, `secure=true` (prod), `same_site=lax`
479
+ - [ ] `config/cors.php`: `supports_credentials=true`, specific `allowed_origins`
480
+ - [ ] All endpoints have `auth:sanctum` middleware (or explicit public reasoning)
481
+ - [ ] All input validated via FormRequest with Policy in `authorize()`
482
+ - [ ] Rate limiting: `throttle:auth` on `/login`, `throttle:api` on protected
483
+ - [ ] Brute force protection on login (progressive lockout)
410
484
  - [ ] Sensitive fields `$hidden` on models
411
- - [ ] API keys encrypted at rest
412
- - [ ] Audit logging on sensitive models
413
- - [ ] Brute force protection on login
414
- - [ ] Request ID tracking in all logs
415
- - [ ] No `env()` in code (only `config()`)
485
+ - [ ] API tokens encrypted at rest (`'encrypted'` cast on `ApiCredential`)
486
+ - [ ] Audit logging on sensitive models (Auditable trait)
487
+ - [ ] Request ID tracking in all logs (X-Request-ID middleware)
488
+ - [ ] Security headers middleware (CSP, HSTS, X-Frame-Options, X-Content-Type)
489
+ - [ ] No `env()` outside `config/*.php`
416
490
  - [ ] No `*` in token abilities
417
491
  - [ ] No `$guarded = []` on models
418
- - [ ] CSP headers configured
419
- - [ ] HSTS enabled
420
- - [ ] Webhook signatures verified
421
- - [ ] Error responses don't expose internals
492
+ - [ ] No `Inertia::render()` for new endpoints (use API + Resource)
493
+ - [ ] No tokens in `localStorage` (cookie auth for SPAs)
494
+ - [ ] Webhook signatures verified before processing
495
+ - [ ] Error responses don't expose stack traces in production
422
496
 
423
497
  ## FORBIDDEN
424
498
 
425
499
  | ❌ Don't | ✅ Do |
426
500
  |---|---|
501
+ | Store JWT/Sanctum token in `localStorage` | HttpOnly session cookie (Sanctum SPA) |
502
+ | `'allowed_origins' => ['*']` with credentials | Specific origins or patterns |
503
+ | `axios.defaults.withCredentials = false` | `true` is MANDATORY for cookie auth |
504
+ | `same_site=none` over HTTP | `none` requires `secure=true` (HTTPS) |
427
505
  | `$guarded = []` | Explicit `$fillable` |
428
- | `'allowed_origins' => ['*']` | Your domain only |
429
- | `createToken('x', ['*'])` | Specific abilities |
430
- | `"WHERE email = '$email'"` | Parameterized queries |
431
- | `dd($user)` in production | `Log::info()` structured |
432
- | Generic error messages with stack traces | Clean error + request_id for debugging |
433
- | Store API keys in plaintext | `'encrypted'` cast |
506
+ | `createToken('x', ['*'])` | Specific abilities (least privilege) |
507
+ | `"WHERE email = '$email'"` | Parameterized queries / Eloquent |
508
+ | `dd($user)` in production | Structured `Log::info()` |
509
+ | Generic error responses leaking stack trace | Clean message + `request_id` for support |
510
+ | Store API keys in plaintext | `'encrypted'` cast on the column |
434
511
  | Same rate limit for all endpoints | Progressive: auth(5/min) < api(60/min) |
435
- | Trust `X-Forwarded-For` directly | Use trusted proxies config |
436
- | No token expiry | 24h max, rotate on sensitive actions |
512
+ | Trust `X-Forwarded-For` directly | Use Laravel's trusted proxies config |
513
+ | No token expiry | 24h max + rotate on sensitive actions |
514
+ | Login route on `/api/*` | Login goes on `routes/web.php` (session middleware) |
515
+ | `Inertia::render()` for new endpoints | API + Resource consumed by Axios |