Blog
Symfony Security — When Firewalls and Authentication Start Conflicting
Login may work in one part of the application while the API returns 401 or redirects to HTML. The cause is often a different firewall, authenticator or access rule than the team expects.
The admin session works. Why does the API return 401?
Consider an application where signing in opens the Symfony administration panel and its protected HTML pages. The same browser then requests:
GET /api/admin/pages HTTP/1.1
Host: app.example.com
Accept: application/jsonThe API responds:
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer realm="admin-api"
Content-Type: application/json
{"error":"authentication_required"}Here the API expects a bearer token, while signing in to the panel establishes a session. Both requests come from the same browser, but that alone does not make the session a credential the API accepts.
The examples use Symfony 7.4 and PHP 8.2 or later. Configuration fragments are incomplete; adapt them to the installed version and the application’s authentication design.
First identify the matching firewall
The first thing I would check is which firewall actually matched /api/admin/pages. A Symfony firewall groups authentication settings for matching requests. Symfony selects the first match in configuration order, even when a later pattern is more specific.
The following incomplete configuration is deliberately misordered:
security:
firewalls:
main:
pattern: ^/
lazy: true
api:
pattern: ^/api
stateless: truemain captures /api/admin/pages, so api is never considered. lazy: true does not alter that choice. If separate firewalls are intended, put the specific matcher before the catch-all, then check its authenticator, user provider and access rules. Reordering alone does not set up bearer authentication.
Inside the selected firewall, authentication may come from the session or from applicable authenticators; a public request may stay anonymous. Symfony’s security token represents the resulting identity and is distinct from the bearer string sent over HTTP. Access checks can run before the controller through access_control, through controller attributes such as #[IsGranted], or wherever code explicitly asks for an authorisation decision.
Once the matching firewall is known, check which credential it expects. One possible design is:
/admin session cookie stateful
/api Authorization: Bearer <token> statelessIn this design, stateless: true means the API firewall does not restore authentication from the admin session. That explains a 401 when the browser sends only the session cookie. A browser-facing admin API can instead use session authentication; the /api prefix does not dictate either model.
Separate stateful firewalls can share a configured context if their session handling and user providers are compatible. Every participating firewall must use stateless: false; the shared session context does not authenticate requests under an independent stateless firewall.
Check which authenticator applies
A custom authenticator's supports() decides whether that authenticator should handle the request. A condition such as str_starts_with($request->getPathInfo(), '/api') also matches /apiary and says nothing about the presence of a credential.
This method belongs to an illustrative authenticator that handles supplied Authorization headers under /api. Its authentication logic accepts Bearer and rejects unsupported schemes or invalid tokens. Request is Symfony\Component\HttpFoundation\Request; the rest of the class is omitted:
public function supports(Request $request): bool
{
$path = $request->getPathInfo();
$isApiPath = $path === '/api' || str_starts_with($path, '/api/');
return $isApiPath && $request->headers->has('Authorization');
}false skips this authenticator; the route’s access checks still apply. true selects it for authentication without validating the token. If another authenticator handles a different Authorization scheme, their selection rules must distinguish those schemes.
A public route can also use optional authentication. PUBLIC_ACCESS does not prevent an authenticator from running, and an invalid credential can therefore produce an authentication failure even on that route. Check overlapping supports() conditions and failure handling when several authenticators are configured.
When a protected request needs authentication, the configured entry point tells the client how to begin: perhaps a login redirect for HTML or a 401 response for the API. The JSON body belongs to the application’s contract. Symfony 7.4 can select a single eligible entry point automatically; several candidates require an explicit entry_point. A failure handler can answer directly when an authenticator rejects credentials.
When a JSON request receives HTML
A browser's default fetch follows redirects. These are two separate responses in a possible exchange:
HTTP/1.1 302 Found
Location: /login
HTTP/1.1 200 OK
Content-Type: text/htmlA call to response.json() then tries to parse the login page. The parsing error can hide the authentication failure, even though the final status is 200. This small browser-side diagnostic checks the response before parsing it:
async function loadAdminPages() {
const response = await fetch('/api/admin/pages', {
headers: { Accept: 'application/json' },
});
if (response.redirected) {
throw new Error(
'Unexpected redirect; check authentication.',
);
}
if (!response.ok) {
throw new Error('HTTP ' + response.status);
}
const mediaType = response.headers.get('content-type')
?.split(';')[0].trim().toLowerCase();
if (mediaType !== 'application/json') {
throw new Error(
'Expected an application/json response.',
);
}
return response.json();
}Accept requests a format; it does not configure Symfony’s entry point. The snippet expects application/json, so adjust that check if the API uses another JSON media type. It sends eligible same-origin cookies by default, but supplies no bearer token: the opening API would still reject it.
response.redirected detects a redirect after it has happened. To prevent redirects, use redirect: 'error' and handle the rejected promise.
Separate authentication from authorisation
Authentication establishes the caller's identity. Authorisation decides whether that caller may perform the requested operation. A valid session or token is not permission to delete a page.
Like firewall matching, access_control uses the first matching rule. This excerpt is intentionally wrong for an admin-only API:
security:
access_control:
- { path: ^/api, roles: PUBLIC_ACCESS }
- { path: ^/api/admin, roles: ROLE_ADMIN }For /api/admin/pages, only the public rule is selected. The intended ROLE_ADMIN requirement has disappeared from access_control. A controller attribute or explicit authorisation check may still protect the operation, but the configuration no longer provides the restriction shown in its second line. Put the specific restriction first and reserve public access for resources intended to be anonymous.
The role check is a separate question. With this configuration, an administrator also receives editor permissions through the hierarchy:
security:
role_hierarchy:
ROLE_ADMIN:
- ROLE_EDITORIt does not grant administrators' rights to editors. Use Symfony's authorisation checks to account for the hierarchy rather than treating the raw result of getRoles() as the complete effective permission set.
A role alone may still be insufficient. For example, a page policy might require the caller to own the page and belong to its team, with the page in the current tenant and still a draft. A call such as $this->denyAccessUnlessGranted('PAGE_DELETE', $page) in a controller asks for that object-level decision. A voter participates when it supports the requested attribute and subject; the decision strategy and other voters affect the outcome. Hiding the delete button in React does not enforce this policy.
Read the status alongside the configured response contract:
401 Unauthorizedconventionally indicates missing or invalid authentication credentials for the target resource. HTTP specifies aWWW-Authenticatechallenge; the opening example uses Bearer.403 Forbiddenmeans the server refuses the request. It does not prove that a user was authenticated: an anonymous request, a CSRF failure or another policy can also be refused.- A redirect to login may be correct for an HTML client. Inspect the original response and the redirect chain, not just the final page or a JSON parsing error.
Browser requests and Next.js server requests are different
If navigation works but a refresh fails, compare where the two API calls run. A Server Component or route handler makes its own request to Symfony. App Router navigation can involve server work too, so the navigation type alone does not identify the fetch context.
Browser -> Symfony
Cookies: subject to scope and credentials mode.
Bearer: supplied explicitly in Authorization.
Browser -> Next.js -> Symfony
Symfony receives only credentials that the server
deliberately adds to its own outgoing request.A server-side fetch does not inherit the incoming browser’s cookies or Authorization header, and credentials: 'include' does not forward them. Read the incoming context using the installed Next.js version’s APIs and send only the intended credential to a trusted Symfony destination. Forwarding all headers, accepting a caller-controlled destination or sharing a cached user response across users would create separate security problems.
Cookie scope
For a browser request, inspect the cookie actually sent to the API. An origin is a scheme, host and port; cookies use different scope rules and are not isolated by port:
Path=/adminexcludes/api. Path matching controls sending; it is not an authorisation boundary.- Without
Domain, a cookie is host-only. A permitted parentDomainextends its scope to subdomains, which also widens exposure. Securerestricts sending to secure connections, subject to browsers' local-development exceptions.HttpOnlyprevents JavaScript from reading the cookie; it does not stop the browser including it infetch.SameSiteconcerns sites, not merely origins. Two HTTPS subdomains can be same-site but cross-origin. Cross-site cookies usingSameSite=NonerequireSecure, and browser privacy rules can still block them.
Explicit SameSite=Lax permits some cross-site top-level navigations using safe methods, such as following a GET link; that exception does not cover fetch. Strict excludes cross-site sending. credentials: 'include' overrides neither setting nor host and path restrictions. Keep the scope limited to the intended session flow and use HTTPS in production.
Cross-origin requests and CSRF
Suppose the browser calls https://api.example.com from https://frontend.example.com. These origins differ. Cookie authentication then needs credentials: 'include', eligible cookies and a CORS response allowing the frontend origin. The following response headers illustrate that specific arrangement:
Access-Control-Allow-Origin: https://frontend.example.com
Access-Control-Allow-Credentials: true
Vary: OriginCheck the origin against an allowlist before returning it; * cannot authorise credentialed browser access. If a preflight is required, its response must also allow the intended method and headers, explicitly including Authorization when used. The preflight OPTIONS carries neither the session cookie nor the bearer token of the actual request, so a 401 there can stop the API call before it is sent.
CORS governs browser access to cross-origin responses and gates preflighted requests. Some requests still reach the server even when JavaScript cannot read their responses. The actual operation therefore needs its own authentication and authorisation; CORS supplies neither, and does not constrain a server-to-server fetch in the same way.
For state-changing operations, CSRF protection follows the credential transport. Automatically sent cookies need an appropriate defence, such as validated CSRF tokens or a carefully designed origin check. An explicitly supplied Authorization: Bearer header changes the classic CSRF exposure, but a token stored and sent as a cookie—or a fallback accepting automatic credentials—still needs review. HttpOnly alone does not prevent CSRF. Disabling CSRF globally or opening CORS to arbitrary origins is no fix for this authentication failure.
Define what logout invalidates
Ending the admin session does not necessarily revoke a separately issued API token. Deleting a token from localStorage removes that browser's copy; a copy held elsewhere can remain valid until expiry or server-side revocation, where supported.
Specify whether logout ends this session, revokes API tokens, prevents refresh-token renewal or covers all of these. Test the chosen behaviour, including expiry, against the server’s response to the old credentials.
Test both refusal and authorised access
For the bearer API in this example, assume anonymous reads return 401, an authenticated editor’s deletion returns 403, and an authorised administrator’s deletion returns 204. These statuses are the example’s contract.
The abstract methods below are placeholders to implement in a concrete test subclass, not existing Symfony or project helpers. Supply isolated fixture users and tokens accepted by the real authenticator. Each deletion test needs an existing page that is otherwise deletable: the editor lacks permission, while the administrator satisfies ownership, tenant and state restrictions.
pageExists() must read persisted data afresh. The assertions assume immediate deletion; adapt them for soft deletion or queued work. Use test credentials and data only.
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
abstract class AdminApiSecurityTestCase extends WebTestCase
{
abstract protected function editorBearerToken(): string;
abstract protected function adminBearerToken(): string;
abstract protected function deletablePageId(): int;
abstract protected function pageExists(int $pageId): bool;
public function testAnonymousUserCannotAccessAdminApi(): void
{
$client = static::createClient();
$client->request('GET', '/api/admin/pages', server: [
'HTTP_ACCEPT' => 'application/json',
]);
self::assertResponseStatusCodeSame(401);
self::assertResponseHeaderSame(
'WWW-Authenticate',
'Bearer realm="admin-api"',
);
}
public function testEditorCannotDeletePage(): void
{
$client = static::createClient();
$token = $this->editorBearerToken();
$pageId = $this->deletablePageId();
$client->request(
'DELETE',
'/api/admin/pages/' . $pageId,
server: [
'HTTP_ACCEPT' => 'application/json',
'HTTP_AUTHORIZATION' => 'Bearer ' . $token,
],
);
self::assertResponseStatusCodeSame(403);
self::assertTrue($this->pageExists($pageId));
}
public function testAdminCanDeletePage(): void
{
$client = static::createClient();
$token = $this->adminBearerToken();
$pageId = $this->deletablePageId();
$client->request(
'DELETE',
'/api/admin/pages/' . $pageId,
server: [
'HTTP_ACCEPT' => 'application/json',
'HTTP_AUTHORIZATION' => 'Bearer ' . $token,
],
);
self::assertResponseStatusCodeSame(204);
self::assertFalse($this->pageExists($pageId));
}
}The editor test checks both refusal and preservation of the page. The administrator test checks success and the resulting deletion. Together they catch a system that simply denies everyone; a 403 by itself would not show that the intended permission rule was exercised.
For a session-based API, use a fixture user with loginUser($user, $firewallContext) and the actual shared context where configured. This helper bypasses login and does not work with stateless firewalls, so test the real login flow separately. Supply valid CSRF data wherever required rather than disabling the protection for tests.
Extend coverage to expired or invalid tokens, logout and cross-tenant access as appropriate. Check cookie, CORS, CSRF and redirect behaviour in browser tests as well: Symfony’s test client does not enforce all browser policies.
Trace the failing request
Work through one failed request in this order, separating configuration facts from assumptions:
- Capture the method, exact URL, status,
Content-Typeand redirect chain. Identify whether the response originated in Symfony or an upstream proxy. - Determine the matching firewall, session context and
statelesssetting. Inspect the deployed environment's effective configuration, not just an isolated YAML fragment. - Check applicable authenticators, the entry point and failure handlers. Establish whether the expected credential actually arrived; inspect validity and user loading through controlled diagnostics.
- Identify the resulting user, if any, then the first matching
access_controlrule and any controller, voter or explicit authorisation checks. - Compare browser and Next.js server requests, including the destination, cookie scope and deliberately forwarded authentication data.
The Symfony Profiler's Security information can help in a protected development environment. Keep it private. Production logs should record bounded metadata such as a request correlation ID, firewall, outcome and credential presence—not passwords, bearer values, complete cookie headers or session secrets. Redact exported request captures too.
Reference documentation
Version-sensitive details should be checked against the deployed dependencies:
- Symfony 7.4: firewalls, authentication and role hierarchy — https://symfony.com/doc/7.4/security.html
- Firewall context and stateless configuration — https://symfony.com/doc/7.4/reference/configuration/security.html
- Access-rule matching — https://symfony.com/doc/7.4/security/access_control.html
- Custom authenticators and entry points — https://symfony.com/doc/7.4/security/custom_authenticator.html and https://symfony.com/doc/7.4/security/entry_point.html
- Authentication in functional tests — https://symfony.com/doc/7.4/testing.html#logging-in-users-authentication
- Browser cookies and CORS — https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Set-Cookie and https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CORS
- Detecting and blocking fetch redirects — https://developer.mozilla.org/en-US/docs/Web/API/Response/redirected
- Next.js request headers — https://nextjs.org/docs/app/api-reference/functions/headers
- CSRF defence — https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html
- HTTP status semantics — https://www.rfc-editor.org/rfc/rfc9110.html#name-status-codes
Follow the failing HTTP request through its matching security context before changing login, permissions or frontend code.
