7 min read
Blocking Direct Browser Access to Laravel APIs

A few months back, I built a REST API for a mobile app. Everything worked great—development was smooth, testing passed, the mobile app consumed the API perfectly. Then one day, out of curiosity, I opened one of the API endpoints directly in my browser.

Every single piece of data showed up. Just like that. No barriers.

For a public API, that’s fine. But this was meant exclusively for a specific mobile app. Some endpoints weren’t supposed to be accessible by just anyone opening a URL in Chrome.

That got me thinking: “How do I restrict API access so only valid applications can hit these endpoints?”

What’s the Problem?

Out of the box, Laravel APIs are accessible from anywhere:

  • Browsers (Chrome, Safari, Firefox)
  • API Clients (Postman, Bruno, Insomnia)
  • Mobile Apps (Android, iOS)
  • Scripts (cURL, Python, anything really)

For truly public APIs like weather services or open datasets, that’s expected behavior. But what if your API:

  • Serves a specific mobile app only
  • Handles sensitive user data
  • Has strict rate limiting needs
  • Requires tracking request sources

You need a way to validate: “Is this request coming from my app, or just someone typing a URL in their browser?”

The Solution: Middleware + Header Validation

The concept is straightforward:

  1. Every request must include a secret key in the headers
  2. Requests must accept JSON, not HTML
  3. If either check fails, reject the request

Here’s the flow:

Request → Middleware Check → Valid? → Controller

                       Invalid → 403 Error

Simple, effective.

Implementation Walkthrough

1. Create the Middleware

Generate a new middleware:

php artisan make:middleware RestrictDirectAccess

Edit app/Http/Middleware/RestrictDirectAccess.php:

<?php

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;

class RestrictDirectAccess
{
    public function handle(Request $request, Closure $next): Response
    {
        // Check 1: Validate API Key from header
        $appKey = $request->header('X-App-Key');
        $validKey = config('app.api_key');
        
        if ($appKey !== $validKey) {
            return response()->json([
                'success' => false,
                'message' => 'Unauthorized. Invalid API key.'
            ], 403);
        }
        
        // Check 2: Ensure request expects JSON (not HTML)
        if (!$request->expectsJson()) {
            return response()->json([
                'success' => false,
                'message' => 'Direct browser access not allowed.'
            ], 406);
        }
        
        return $next($request);
    }
}

This middleware performs two checks:

  • Validates the X-App-Key header against your secret
  • Ensures the request expects JSON (browsers typically expect HTML)

2. Register the Middleware

Laravel 11:

Edit bootstrap/app.php:

->withMiddleware(function (Middleware $middleware) {
    $middleware->alias([
        'restrict.access' => \App\Http\Middleware\RestrictDirectAccess::class,
    ]);
})

Laravel 10 and earlier:

Edit app/Http/Kernel.php:

protected $middlewareAliases = [
    'restrict.access' => \App\Http\Middleware\RestrictDirectAccess::class,
];

3. Configure Your Secret Key

Add to .env:

API_APP_KEY=your-super-secret-key-here

Generate a random secure key:

php artisan tinker
>>> Str::random(40)

Register in config/app.php:

return [
    'api_key' => env('API_APP_KEY', 'fallback-key'),
];

4. Apply to Routes

Edit routes/api.php:

use App\Http\Controllers\Api\ProductController;

// Protected routes - require X-App-Key header
Route::middleware('restrict.access')->group(function () {
    Route::get('/products', [ProductController::class, 'index']);
    Route::get('/products/{id}', [ProductController::class, 'show']);
    Route::post('/products', [ProductController::class, 'store']);
});

// Public endpoint - no middleware
Route::get('/health', function () {
    return response()->json(['status' => 'ok']);
});

Excluding specific routes:

Sometimes you need exceptions (like webhooks from payment providers):

Route::middleware('restrict.access')->group(function () {
    Route::get('/products', [ProductController::class, 'index']);
    
    // Webhook callback - skip middleware
    Route::post('/webhook/payment', [WebhookController::class, 'handle'])
        ->withoutMiddleware('restrict.access');
});

Testing It Out

Browser Access (Should Fail)

Open in any browser:

https://api.yourdomain.com/api/products

Expected response:

{
  "success": false,
  "message": "Unauthorized. Invalid API key."
}

Status: 403 Forbidden

API Client Access (Should Succeed)

Bruno/Postman setup:

Headers:

Accept: application/json
X-App-Key: your-super-secret-key-here

Request:

GET https://api.yourdomain.com/api/products

Expected response:

{
  "success": true,
  "data": [
    { "id": 1, "name": "Product A" },
    { "id": 2, "name": "Product B" }
  ]
}

Status: 200 OK

cURL Testing

Without headers (rejected):

curl https://api.yourdomain.com/api/products

With proper headers (accepted):

curl -H "Accept: application/json" \
     -H "X-App-Key: your-super-secret-key-here" \
     https://api.yourdomain.com/api/products

Enhancing Security

The middleware works, but you can level it up:

1. Rate Limiting

Combine with Laravel’s built-in throttling:

Route::middleware(['restrict.access', 'throttle:60,1'])->group(function () {
    // Max 60 requests per minute
    Route::get('/products', [ProductController::class, 'index']);
});

2. Access Logging

Track rejected attempts for monitoring:

if ($appKey !== $validKey) {
    Log::warning('Invalid API key attempt', [
        'ip' => $request->ip(),
        'endpoint' => $request->fullUrl(),
        'user_agent' => $request->userAgent(),
    ]);
    
    return response()->json([
        'success' => false,
        'message' => 'Unauthorized. Invalid API key.'
    ], 403);
}

3. Multiple API Keys

Different keys for different platforms:

// config/app.php
'api_keys' => [
    'android' => env('API_KEY_ANDROID'),
    'ios' => env('API_KEY_IOS'),
    'web' => env('API_KEY_WEB'),
],

// Middleware
$appKey = $request->header('X-App-Key');
$validKeys = config('app.api_keys');

if (!in_array($appKey, $validKeys)) {
    return response()->json(['message' => 'Invalid API key'], 403);
}

// Optional: Track which platform made the request
$platform = array_search($appKey, $validKeys);
Log::info("Request from: {$platform}");

Important Considerations

HTTPS is mandatory. Without SSL/TLS, your API key can be intercepted during transmission. Never run this in production without HTTPS.

API keys can be extracted. A determined person can decompile your mobile app and find the key. This prevents casual access, not sophisticated attacks.

For production-grade security, layer this with:

  • OAuth 2.0 or Laravel Sanctum for user authentication
  • Certificate pinning in your mobile app
  • Proper input validation and sanitization
  • Regular security audits

When to Use This Approach

✅ Good fit when:

  • Building API for a specific mobile app
  • Preventing casual browser access is enough
  • You need basic request source validation
  • Combined with proper authentication

❌ Skip it when:

  • Your API is intentionally public
  • You already have OAuth/JWT authentication
  • You need interactive API documentation (like Swagger)
  • Third-party integrations need easy access

Real-World Usage

I’ve used this pattern in several projects:

POS Mobile App:

  • API exclusively for the app
  • Prevented direct browser snooping
  • Combined with Laravel Sanctum for user auth
  • Rate limiting per device

Delivery Platform:

  • Different keys for customer app, driver app, admin panel
  • Logging to track usage patterns
  • Easy to rotate keys when needed

Works well for internal tools and controlled-access APIs.

Final Thoughts

Restricting browser access does not mean making your API fully secure. It is simply an additional validation layer to help ensure requests come from expected sources.

Think of it as a first checkpoint. Requests must bring the right “ticket” (API key) and use the correct format (JSON). Beyond that, your API still needs other security measures such as authentication, authorization, and input validation.

Security should always be layered, and this middleware is just one layer among many.

Best Practices

  • Always use HTTPS in production
  • Combine with user authentication (Sanctum / Passport)
  • Implement rate limiting
  • Validate all incoming inputs
  • Log suspicious activity
  • Monitor your system regularly

Start simple, then improve security based on your real application needs.


References: