Security & deployment posture¶
This page covers the bundle-side security and deployment surface: where your firewall sits relative to JSON:API routes, what debug output is gated in production, and the guarantees the bundle does — and does not — make. It is a short page by design; the spec-level posture (PII in error documents, the JSON:API security considerations) is owned by core's security doc.
The bundle adds no auth of its own¶
The bundle ships no authentication or authorization. JSON:API endpoints are
ordinary Symfony routes emitted by the route loader (see routing),
so you place a firewall and authenticators in security.yaml exactly as you would
for any route — per path or host pattern, with access_control rules, voters, or
#[IsGranted] on whatever you layer in front.
Two properties of the route loader make this work cleanly:
- Routes are literal, not a catch-all. The loader emits one concrete path per
type and operation (
GET /albums,POST /albums,GET /albums/{id}, …) — it does not register a single parametric/{type}catch-all. Soaccess_controlpatterns and per-route firewalls match JSON:API paths the same way they match any other route; the router itself404s an unknown type before security runs. - Multi-server prefixes are natural firewall boundaries. When you mount a named
server under a prefix (the example app mounts an
adminserver under/admin— see multi-server-and-testing), a^/adminfirewall oraccess_controlrule scopes that whole server in one line:
# config/packages/security.yaml (your app — the bundle ships none)
security:
firewalls:
admin_api:
pattern: ^/admin
# … your authenticator(s)
access_control:
- { path: ^/admin, roles: ROLE_ADMIN }
The example app intentionally ships no firewall (it has nothing to protect), so the recipes here are prose — but the mechanism is exactly Symfony's, unchanged.
Declarative per-resource authorization (optional)¶
On top of the firewall, the bundle offers an optional declarative-authorization
layer: a resource declares Symfony Security expressions on #[AsJsonApiResource]
(security:, securityCreate:, …) and the bundle evaluates them at the right
lifecycle hook for each operation — denying a write before it persists, gating a
single read, delegating is_granted('EDIT', object) to a Voter. It activates only
when symfony/security-core is installed and a firewall is configured. See
authorization for the full surface.
Firewall failures still render as JSON:API¶
Because the route-scoped ExceptionListener (see errors) maps any
Symfony HttpExceptionInterface to a status-keyed JSON:API error, a security
exception on a JSON:API route renders as a spec-compliant error document, not
an HTML login redirect:
| Thrown by the firewall | Status | Rendered error title |
|---|---|---|
UnauthorizedHttpException (no credentials) |
401 |
Unauthorized |
AccessDeniedHttpException (denied) |
403 |
Forbidden |
AccessDeniedException (Security, e.g. the declarative layer) |
403 (or 401 when unauthenticated) |
Forbidden / Unauthorized |
AuthenticationException (Security) |
401 |
Unauthorized |
The last two are Symfony Security exceptions that are not HttpExceptionInterface,
so the listener maps them explicitly (guarded so it compiles without
symfony/security-core) — this is what lets the declarative-authorization layer
(authorization) render a clean 403/401.
So authentication and authorization compose with JSON:API rendering for free. The
one thing to configure on your side: point the firewall's entry_point and
access_denied_handler at handlers that return the relevant HttpException
(or a vnd.api+json response) rather than redirecting to a login form — otherwise
an unauthenticated API client gets a 302 to HTML. The listener owns the body once
the exception is thrown; the firewall owns what gets thrown.
The error model the mapping rides on is core's — see errors-and-exceptions.
Debug-meta gating — a production checklist item¶
The ExceptionListener's $debug flag is bound from %kernel.debug% in the
bundle's service config:
// config/services.php
$services->set(ExceptionListener::class)
->args([
// …
'$debug' => '%kernel.debug%',
'$logger' => service('logger')->nullOnInvalid(),
])
->tag('kernel.event_listener', ['event' => 'kernel.exception', 'priority' => 128]);
With debug off (production), the listener redacts everything beyond the spec's stable error fields:
- A generic
500carries no{exception, file, line, trace}meta, nodetail, and nocode— byte-identical to what core's own middleware would emit (InternalServerError::for($throwable, false)). - An
HttpException-derived error (e.g. a403) carries nodetail— the underlying message is suppressed (detail: $this->debug ? $throwable->getMessage() : '').
With debug on, the {exception, file, line, trace} meta and the detail
render — useful in dev, a leak in prod. This is exercised both ways in the
bundle's ExceptionListenerTest and witnessed end to end (debug off) by the
example app's
ErrorHandlingTest::errorDocumentsLeakNoDebugMetaWithDebugOff().
The single rule: ship production with APP_ENV=prod so kernel.debug is false.
That one setting closes the entire debug-leak surface — there is no separate bundle
flag to remember.
A related kernel setting worth turning on so the listener actually owns every
failure: framework.handle_all_throwables: true (the example app sets it) routes
every throwable through kernel.exception, so even a non-HttpException becomes a
JSON:API 500 rather than a framework HTML error page.
Request body size — capped at the edge, not by the bundle¶
The bundle imposes no request-body-size limit. On a write verb the lifecycle
reads the body and json_decodes it as core negotiates (see
lifecycle); a hostile multi-megabyte body is parsed into memory like
any other. Cap request size where Symfony apps always do — at the edge:
- the web server / reverse proxy (
client_max_body_sizein nginx,LimitRequestBodyin Apache), and/or - PHP's
post_max_size/memory_limit.
The optional opis structural linter (json_api.schema_validation, see
validation) runs after the body is decoded — it validates
document shape, not size, so it is not a size guard.
Query columns are server-side only¶
Filter and sort columns never come from the client. The Doctrine reference layer
(see doctrine) takes column names from the server-side resource
declaration, regex-validates each as a Doctrine field path before interpolating
it into DQL, and always binds the value as a query parameter — so a client cannot
inject a column name or a value into the query. The bundle's own bound-parameter
placeholders are namespaced with a reserved jsonapi_ prefix to stay
collision-free with an entity's own mappings. Filter/sort parameter keys the
client supplies are matched against the declared vocabulary and rejected with a
400 (FILTERING_UNRECOGNIZED / SORTING_UNRECOGNIZED) when unknown.
What the bundle guarantees — and what it doesn't¶
| The bundle guarantees | The bundle does NOT provide (you own it) |
|---|---|
Route-scoping — it never hijacks non-JSON:API routes (the ExceptionListener acts only on routes carrying _jsonapi) |
Authentication / authorization (your firewall + voters) |
Debug-meta redaction in production (%kernel.debug%-gated) |
Rate limiting (e.g. symfony/rate-limiter) |
| Server-side-only, parameter-bound filter/sort columns | Request body-size limits (the edge / PHP) |
Spec-compliant error documents for firewall 401/403 |
CORS (e.g. nelmio/cors-bundle) |
| CSRF (not applicable to stateless token APIs; your concern if you run cookie sessions) |
Everything in the right column is a standard Symfony or edge concern, deliberately left to you — the bundle does not reinvent the framework's security stack, it slots into it.
Next / See also¶
- errors — the route-scoped
ExceptionListener, the full status mapping table, and the debug gating in detail. - routing — why the routes are literal (so
access_controlmatches them) and the route-defaults contract. - multi-server-and-testing — per-server prefixes as firewall boundaries.
- Core security — the spec-level posture (PII in errors, JSON:API security considerations) the bundle inherits.