The least privileged principle applied to Symfony firewalls
The concept that describes the isolation of critical parts of a system to increase security is known as the principle of least privilege or least privilege principle. This principle suggests that each component or part of the system should have the least amount of privilege necessary to perform its specific functions.
By applying the principle of least privilege, it is possible to reduce the system’s attack surface by limiting access and exposure to sensitive or critical parts. This means that even if one part of the system is compromised, access to other parts is restricted, minimizing potential damage.
The principle of least privilege is not a silver bullet and does not solve all problems; the concept has pros and cons that should be taken into consideration before applying it.
HTTP Cache
The principle utilizes HTTP cache to enhance the performance of your API. With an efficient cache policy, you can store common responses in your reverse proxy and reduce the frequency of requests to your application. For a request to be cacheable, it must meet the following conditions:
- Not contain any private data 1
- Not be associated with any user
- Be public
- Have infrequent changes in response
- Not pose any risks to the application
If you have API routes where the results change infrequently and the responses don’t reveal significant secrets of your application, then you can apply the principle of least privilege to those routes. Here are some examples:
- Cities in a country
- Blog posts
- Public user profiles
- Songs by a specific artist
Every piece of data, even if publicly available, must not compromise any secrets. It should be cached in a reverse proxy like nginx, ingress, or Cloudflare, and the same data should later be stored in a user’s browser without any risk of leakage.
Where is the minimum privilege?
Here comes the neat part: The application won’t have access to the user’s data, the minimum privilege resides in the application itself.
The Symfony framework doesn’t make public responses by default for security reasons. When the user session is initialized, the response automatically becomes private. In other words, if the user sends any cookie along with the request, the framework itself takes care of user identification. In this scenario, you are allowing your app to access the user’s data by letting your route use cookies. Even if you don’t use any data at the code level, the framework is still identifying the user under the hood, resulting in unnecessary database queries and RAM usage for Doctrine entities. Besides not being able to identify who is making the request, the programmer must exercise good judgment and avoid linking any confidential data to public responses.
Symfony firewalls
The principle is applied through Symfony firewalls where one specific firewall can be designed for public routes and be responsible for all routes that follow the least privilege principle.
Symfony, by default, comes with the following configuration:
---
security:
firewalls:
dev:
pattern: ^/(_(profiler|wdt)|css|images|js)/
security: false
main:
lazy: true
provider: app_user_provider
json_login:
check_path: login
access_control:
- { path: ^/api/admin/, roles: ROLE_USER }
- { path: ^/, roles: PUBLIC_ACCESS }
Where dev is the firewall used in the dev environment and main is the firewall that encompasses the entire application. The main firewall is the one who dictates which kind of user can access which part of the application.
The public firewall
Let’s create another firewall that will be public and used for the principle of least privilege. This firewall will be stateless and will not be “secure”. The reason is that the routes under this firewall must not access user information, thus not identifying the user.
Edit your security.yaml with the following changes:
---
security:
firewalls:
dev:
pattern: ^/(_(profiler|wdt)|css|images|js)/
security: false
main:
lazy: true
provider: app_user_provider
json_login:
check_path: login
public:
anonymous: true
security: false
request_matcher: App\Security\RequestMatchers\PublicFirewallMatcher
stateless: true
access_control:
- { path: ^/api/admin/, roles: ROLE_USER }
- { path: ^/api/autocomplete/name-suggestions/, roles: PUBLIC_ACCESS }
- { path: ^/api/blog-post/find/by-id/\d+, roles: PUBLIC_ACCESS }
- { path: ^/, roles: PUBLIC_ACCESS }
Make a new class named PublicFirewallMatcher that implements
RequestMatcherInterface.
<?php
declare(strict_types=1);
namespace App\Security\RequestMatchers;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Matcher\RequestMatcherInterface;
use function in_array;
final readonly class PublicFirewallMatcher implements RequestMatcherInterface
{
/**
* @var string[]
*/
private array $staticRoutes = [
'/api/autocomplete/name-suggestions/',
];
public function matchRequest(Request $request): bool
{
return in_array(
$request->getPathInfo(),
$this->staticRoutes,
true
);
}
}
The class will be instantiated as a service and the framework will use its method to test whether the request matches or not. You can put as many routes as you want and you can even make unit tests to match your business logic. Besides using static routes you can also match dynamic routes by using regexes.
<?php
declare(strict_types=1);
namespace App\Security\RequestMatchers;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Matcher\RequestMatcherInterface;
use function in_array;
final readonly class PublicFirewallMatcher implements RequestMatcherInterface
{
/**
* @var string[]
*/
private array $staticRoutes = [
'/api/autocomplete/name-suggestions/',
];
/**
* @var string[]
*/
private array $routePatterns = [
'/^\/api\/blog-post\/find\/by\-id/\d+/',
];
public function matchRequest(Request $request): bool
{
$matchesStaticRoutes = in_array(
$request->getPathInfo(),
$this->staticRoutes,
true
);
$matchesDynamicRoutes = (bool) count(
array_filter(
$this->routePatterns,
fn (string $routePattern): bool => 1 ===
preg_match($routePattern, $request->getPathInfo())
)
);
return $matchesStaticRoutes || $matchesDynamicRoutes;
}
}
After setting up the firewall, here is how you can use it in the controller.
<?php
declare(strict_types=1);
namespace App\Controller\Api;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Annotation\Route;
class NameSuggestionController extends AbstractController
{
#[
Route(
'/api/autocomplete/name-suggestions/',
name: 'api_autocomplete_name_suggestions',
stateless: true, // <-- Notice the stateless option
methods: [Request::METHOD_GET]
)
]
public function findNameSuggestions(): JsonResponse
{
return $this
->json('ok')
->setPublic()
->setMaxAge(600);
}
}
Your API response will be public and have a maximum age of 10 minutes, which means your reverse proxy will cache it and further requests won’t hit your application again. This is beneficial because it prevents overloading your database or search engine, and even the browser can cache this response.
Benchmarks
You can see the difference in the number of queries in the database of a proprietary application with code similar to the examples in this post.

Figure 1 - No. of queries on an authenticated route
Now the same route with no authentication.

Figure 2 - Database queries with no authentication
All other queries were fetching information about the user that was currently logged in.

Figura 3 - HTTP Cache
It is possible to see the browser’s behavior when receiving a response like this. From the second time onward, it will always fetch from the cache instead of repeating the request. A combination of optimized database queries and browser caching results in a much faster search.
That’s it; I hope this article cleared up any doubts about the principle of least privilege. See you in the next edition.
Private data is understood to include any personal data present in the request URL or response. Even if the data belongs to a private group, it is still considered private. ↩︎