Securing Ajax Routes
UX DataTables registers a small set of Ajax routes automatically. Server-side tables, the inline edit modal, row deletion, detail rendering and template resolution all go through these endpoints:
| Route name | Path | Method(s) | Used by |
|---|---|---|---|
| ux_datatables_ajax_data | /datatables/ajax/data | GET | Server-side data loading |
| ux_datatables_ajax_delete | /datatables/ajax/delete | DELETE | Row delete action |
| ux_datatables_ajax_edit | /datatables/ajax/edit | POST, PATCH | Inline cell edit |
| ux_datatables_ajax_edit_form | /datatables/ajax/edit-form | GET | Edit modal form |
| ux_datatables_ajax_edit_form_submit | /datatables/ajax/edit-form | POST | Edit modal submit |
| ux_datatables_ajax_detail | /datatables/ajax/detail | GET | Row detail rendering |
| ux_datatables_ajax_templates | /datatables/ajax/templates | POST | Template column rendering |
Why this matters
Each rendered table carries a signed token (an HMAC of the DataTable class name, derived from your
application’s kernel.secret). The bundle uses this token to resolve the correct AbstractDataTable
service on the server. The token is stable, it is visible in the page source, and it does not
encode anything about the current user.
This means that if a table is displayed on a page behind an admin firewall, but the global
/datatables/ajax/* routes are not covered by an equivalent access control rule, the underlying
data (and the edit/delete actions) can be reached by anyone who obtains the token — including a user
who is not allowed to view the page that renders the table.
The bundle cannot know how your application authenticates and authorizes users, so it deliberately does not enforce anything at the route level. Securing these routes is the responsibility of the host application.
Protect the routes with access_control
The simplest and most robust approach is to add an access_control rule that scopes the shared
^/datatables/ajax path prefix to match the access requirements of the pages that render your
tables. Add it to config/packages/security.yaml:
# config/packages/security.yaml
security:
# ...
access_control:
# Restrict every UX DataTables Ajax endpoint to authenticated admins.
# Match this to whatever the pages rendering your tables require.
- { path: ^/datatables/ajax, roles: ROLE_ADMIN }
# ... your other rules
If different tables live behind different access levels, use more specific rules or place the tables (and their Ajax traffic) behind the same firewall so the same authorization applies to both the page and the data endpoint.
Verifying your configuration
After adding the rule, confirm it is active:
# List the registered UX DataTables routes
php bin/console debug:router | grep ux_datatables
# Check which access_control rule matches the Ajax prefix
php bin/console debug:firewall
Then, as an unauthenticated (or under-privileged) user, request
/datatables/ajax/data directly and confirm the firewall responds with a redirect to login or a
403/401 instead of returning table data.
Restricting mutations with a Voter
Delete actions and inline boolean toggles also require an active session so the bundle can create
and validate a CSRF token. When rendering happens without a session (for example behind a stateless
firewall), the table payload exposes mutationsEnabled: false; delete buttons and boolean switches
are rendered disabled instead of sending requests that can only fail with 403.
Use these mutation controls only on session-backed pages. A stateless application should provide its own authenticated mutation endpoints and controls rather than relying on the bundle’s session-backed delete and toggle endpoints.
access_control protects the route — it decides who may call /datatables/ajax/* at all. It does
not know anything about the specific row a delete or edit request targets. To close that gap, the
bundle’s EntityMutator calls into Symfony’s authorization layer before mutating anything:
delete()checksisGranted('DELETE', $entity)before removing the entity.setProperty()(the inline boolean toggle) checksisGranted('EDIT', $entity)before writing the field.
If either check is denied, the mutation is rejected and the Ajax endpoint responds with 403
instead of touching the database.
Why nothing gets denied without a security firewall
The check is performed through a small internal wrapper, PermissionChecker, which only grants
everything automatically when no security stack is configured at all — for example in the CLI,
in a unit test, or in an application that never installed symfony/security-bundle. As soon as a
real AuthorizationCheckerInterface is available (i.e. your application has a firewall), the
wrapper defers entirely to Symfony’s normal voting process. With a firewall active but no voter
registered for DELETE/EDIT on your entity, access is denied by default — the safe posture, but
one that also blocks the people who should be allowed to edit or delete. Registering a voter is
what makes the feature usable again for those users, not just a way to lock out attackers.
Writing a Voter
Implement a Symfony Voter that supports the
EDIT and DELETE attributes for each entity exposed through an editable or deletable
Action Column. For example, an entity where only its owner or an
administrator may edit or delete it:
<?php
declare(strict_types=1);
namespace App\Security\Voter;
use App\Entity\Product;
use App\Entity\User;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
/**
* @extends Voter<string, Product>
*/
final class ProductVoter extends Voter
{
public const EDIT = 'EDIT';
public const DELETE = 'DELETE';
public function __construct(
private readonly Security $security,
) {
}
protected function supports(string $attribute, mixed $subject): bool
{
return \in_array($attribute, [self::EDIT, self::DELETE], true)
&& $subject instanceof Product;
}
/**
* @param Product $subject
*/
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool
{
$user = $token->getUser();
if (!$user instanceof User) {
return false;
}
if ($this->security->isGranted('ROLE_ADMIN')) {
return true;
}
return match ($attribute) {
self::EDIT, self::DELETE => $subject->getOwner()?->getId() === $user->getId(),
default => false,
};
}
}
The comparison uses getId() rather than === on the two objects: Doctrine may hand you a
lazy-loading proxy for getOwner() while $user is the fully-hydrated object from the token (or the
reverse), and those are never ===-identical even when they represent the same row.
Voters and access_control are complementary, not alternatives
Keep both layers in place:
access_control(see above) gates the Ajax routes — it is your coarse-grained, per-request perimeter.- Voters gate individual entities — they close insecure direct object reference (IDOR) style issues, where an authenticated, otherwise-authorized user tries to edit or delete a row they don’t own by guessing or tampering with its identifier.
A firewall rule alone would let any authenticated admin-area user delete any row reachable through the table; the voter is what scopes that down to the rows they’re actually allowed to touch.