Your Laravel SPA Is Not a Secret API: A Practical E-Commerce Security Guide!!!
Public product data can be scraped. Private customer data must not be exposed. Here’s how to secure the boundary with Laravel. A product catalog is meant to be seen. A customer’s order history is not. That sounds obvio
Public product data can be scraped. Private customer data must not be exposed. Here’s how to secure the boundary with Laravel.
A product catalog is meant to be seen. A customer’s order history is not.
That sounds obvious—until an e-commerce SPA ships an endpoint that returns more data than the page needs, an order route trusts a guessable ID, or a rate limit is treated as a magic anti-scraping shield.
Let’s separate what can realistically be protected from what cannot, then build a layered Laravel approach that addresses both.
First, a reality check about SPAs
A single-page application does not make its data private. If a browser can display a product’s name and price, a visitor can inspect the network response that delivered them. A competitor does not need to discover a hidden API route; they can use browser developer tools, read the page, or automate requests.
The security goal is therefore not “hide public data.” It is to:
- Expose only the fields the public actually needs.
- Make bulk collection more expensive and detectable.
- Keep customer, operational, and financial data behind authentication and authorization.
- Avoid relying on obscure URLs or frontend checks as security controls.
This distinction is important: public product details and private order records need different defenses.
Threat 1: Product scraping
Public does not mean unlimited
Product names, images, and prices are usually public by design. But a public endpoint that returns every product, all inventory counts, unpublished listings, supplier costs, or internal margin data is a very different matter.
Treat every API response and every Inertia prop as data leaving your server. Use an explicit response shape rather than serializing a full Eloquent model and hoping its hidden fields stay hidden. Laravel API Resources are designed to transform models into the JSON representation returned to clients, including conditional attributes when appropriate. laravel
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class ProductResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'slug' => $this->slug,
'name' => $this->name,
'price' => $this->price,
'image_url' => $this->image_url,
// Do not expose exact stock or internal cost by default.
'in_stock' => $this->stock > 0,
];
}
}
If staff need operational fields, make that a deliberate, authorized response—not a public field with a different frontend display rule. Remember that hiding a value in Vue or React does not remove it from the response.
Rate limits help—but they do not stop scraping
Rate limiting can reduce abusive request volume and protect application capacity. It does not make public data impossible to collect. A determined scraper can slow down, distribute traffic, or use multiple addresses.
Use limits that fit the endpoint and your traffic patterns. For example, search and catalog endpoints may need separate limits, and authenticated customers may be keyed differently from anonymous visitors. Laravel’s rate-limiting tools use the application cache, so configure a suitable shared cache in multi-server deployments. laravel
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;
RateLimiter::for('catalog', function (Request $request) {
$key = $request->user()?->id
? 'user:'.$request->user()->id
: 'ip:'.$request->ip();
return Limit::perMinute(60)->by($key);
});
Route::get('/products', [ProductController::class, 'index'])
->middleware('throttle:catalog');
The example is a starting point, not a universal “safe” limit. Tune it using normal customer traffic and monitoring. Also ensure the application correctly handles proxy headers; otherwise, IP-based limits may identify users incorrectly.
Other useful controls include pagination, reasonable page-size caps, query validation, caching, anomaly monitoring, and bot-management rules at the edge. These controls make bulk extraction harder or less costly to tolerate; they do not turn public catalog data into a secret.
Threat 2: Insecure direct object references
The more serious failure is often an order endpoint like:
GET /api/orders/1042
If the server loads order 1042 and returns it without checking ownership, a logged-in user may change the ID and access another customer’s record. This is an authorization failure—not a problem solved by obscuring the URL.
Use a Laravel Policy to decide whether the authenticated user may view the specific order. Laravel’s authorization system is designed for precisely this distinction: authentication answers who the user is; authorization answers whether that user may act on a given resource. laravel
namespace App\Policies;
use App\Models\Order;
use App\Models\User;
class OrderPolicy
{
public function view(User $user, Order $order): bool
{
return $user->id === $order->user_id;
}
}
Then enforce the policy on the server:
use App\Models\Order;
use Illuminate\Http\Request;
class OrderController
{
public function show(Request $request, Order $order)
{
$this->authorize('view', $order);
return new OrderResource($order);
}
}
The frontend may use permission data to decide whether to show a button. That improves the interface, but the server must still authorize the request. Laravel explicitly treats server-side authorization as authoritative; Inertia props can communicate permissions to the UI, but should not replace policy checks. laravel
UUIDs are not authorization
A UUID or other opaque identifier can make enumeration less convenient, but it does not establish ownership. If a user obtains another order’s identifier—from a link, log, support message, or data leak—the server must still deny access.
Use opaque IDs as defense in depth or for design reasons, not as a substitute for a Policy. The essential test is simple: Does the server verify that this user is allowed to access this record on every relevant request?
SPA authentication: what Sanctum actually does
Laravel Sanctum supports two different patterns: cookie-based authentication for first-party SPAs, and API tokens for clients such as mobile apps or third-party integrations. For a first-party SPA, Sanctum’s documented approach uses Laravel’s session authentication and CSRF protection; it does not simply mean “put a token in an HttpOnly cookie.” laravel
That distinction matters. Configure Sanctum’s stateful domains, CORS, session-cookie settings, and CSRF flow for your deployment. Laravel documents the need for the SPA and API to share the same top-level domain for its SPA-authentication approach, while allowing separate subdomains. laravel
Also, an HttpOnly cookie is not a complete XSS defense. It can prevent JavaScript from reading that cookie directly, but an XSS vulnerability may still let injected code make requests as the user. Continue to prevent XSS, use CSRF protections, and keep authorization checks on the server.
Inertia is an architecture choice, not a security boundary
Inertia can reduce the need to build a separate public API for every page, but it does not make data invisible. Props sent to a browser are visible to that browser. Inertia’s authorization guidance recommends keeping authorization in server-side policies and passing permission results to components when the UI needs them. laravel
A secure Inertia response still requires deliberate data selection:
return Inertia::render('Products/Show', [
'product' => [
'name' => $product->name,
'price' => $product->price,
'description' => $product->description,
],
]);
Avoid passing entire models or broad relationship graphs when the page only needs a few fields. Do the same for shared props: data shared across many pages can quietly become a large, persistent exposure surface.
A practical security checklist
- Catalog responses: Return an explicit public schema; exclude costs, exact stock counts, unpublished records, and customer-specific data.
- Pagination and search: Validate filters, cap page size, and avoid endpoints that return the entire catalog in one response.
- Rate limiting: Apply endpoint-specific limits, use a reliable shared cache where needed, and monitor for scraping patterns.
- Orders and customer records: Require authentication and authorize every record with a Policy or equivalent server-side check.
- Identifiers: Use UUIDs or opaque IDs if useful, but never treat them as access control.
- Sanctum: Use the documented cookie-and-session flow for a first-party SPA; use API tokens for appropriate non-browser clients.
- Inertia props: Send only what the page needs. Frontend visibility rules are not server-side authorization.
- Operations: Log suspicious activity, test cross-account access, and review what each endpoint returns.
The core principle
A public product catalog cannot be made uncopyable merely by choosing Laravel, Inertia, Sanctum, or a WAF. But customer records and internal business data can—and should—be protected by strict server-side authorization and careful response design.
Think of security as a boundary, not a disguise: publish only what is meant to be public, and authorize every private resource on the server.
created by Seyed Alireza Alhosseini Almodarresieh
Further reading
Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.