Authorization in Templates
is_granted in Twig decides what a page shows; the server decides what a request may do, and the two checks are separate.
{{ is_granted(role, object = null) }}A template needs the same answers a controller does: should this edit button appear, should this admin link be in the navigation. is_granted() is the Twig function that asks.
is_granted
It takes an attribute and an optional subject, and runs exactly the check $security->isGranted() runs in PHP: the same role hierarchy, the same voters. The same call in two places, nothing more. It is also safe when nobody is logged in; it returns false rather than failing.
Do not hand-roll it. 'ROLE_ADMIN' in app.user.roles looks like the same test and is not: app.user.roles is the stored list, so a user holding only ROLE_SUPER_ADMIN fails it even though the hierarchy grants ROLE_ADMIN. It also breaks outright when app.user is null.
The template is never the control
Hiding a button removes it from the page. It removes nothing from the application. The URL behind it still exists, and anyone can type it, bookmark it, or replay the form post. Every is_granted() in a template needs a matching check on the server side, in the controller or in access_control, or what you have built is a secret rather than a permission.
So treat the template check as presentation: it keeps the page honest for the person reading it. The controller check is what makes the action impossible. When the two disagree, the reader sees a button that returns 403, which is a bug worth noticing.
Example
<?php
// src/Controller/ArticleController.php
declare(strict_types=1);
namespace App\Controller;
use App\Entity\Article;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
final class ArticleController extends AbstractController
{
#[Route('/articles/{id}', name: 'app_article_show', methods: ['GET'])]
public function show(Article $article): Response
{
// no check needed to render: the template decides what to display
return $this->render('article/show.html.twig', ['article' => $article]);
}
// the server-side half of the hidden button in that template; without this
// attribute, hiding the button protects nothing
#[Route('/articles/{id}', name: 'app_article_delete', methods: ['DELETE'])]
#[IsGranted('ARTICLE_EDIT', 'article')]
public function delete(Article $article, EntityManagerInterface $em): Response
{
$em->remove($article);
// remove() schedules the deletion, flush() performs it
$em->flush();
return $this->redirectToRoute('app_article_index');
}
}
// GET /articles/7 returns 200 for everyone; the author's copy of the page
// carries a Delete button and a stranger's does not. DELETE /articles/7 from a
// stranger returns 403 whether that button was rendered or not.When to use it
- Show an edit button only to the user who is allowed to edit that record.
- Keep an admin link out of the navigation for ordinary users.
- Offer a password-change form only to someone who logged in during this session.
More examples
The button and its subject
One check guards both controls, and it passes the article so the voter can compare authors. Use this shape wherever a page lists actions on an object, and make sure the controller behind each action repeats the check.
{# templates/article/show.html.twig #}
{% extends 'base.html.twig' %}
{% block body %}
<h1>{{ article.title }}</h1>
{# auto-escaping handles the body; no |raw on anything an author typed #}
<div>{{ article.body }}</div>
{# the second argument is the subject handed to the voters #}
{% if is_granted('ARTICLE_EDIT', article) %}
<a href="{{ path('app_article_edit', {id: article.id}) }}">Edit</a>
<form method="post" action="{{ path('app_article_delete', {id: article.id}) }}">
<input type="hidden" name="_method" value="DELETE">
<button type="submit">Delete</button>
</form>
{% endif %}
{% endblock %}Checking a role, correctly and incorrectly
app.user is the right way to ask whether anyone is logged in and to print their identifier. It is the wrong way to ask about permissions. Keep those two uses apart and the navigation stops drifting out of step with the firewall.
{# templates/base.html.twig #}
<nav>
<a href="{{ path('app_home') }}">Home</a>
{# correct: expands role_hierarchy, and is safe when nobody is logged in #}
{% if is_granted('ROLE_ADMIN') %}
<a href="{{ path('app_admin_dashboard') }}">Admin</a>
{% endif %}
{# wrong: reads the stored list, so ROLE_SUPER_ADMIN misses the link,
and it fails outright when app.user is null #}
{# {% if 'ROLE_ADMIN' in app.user.roles %} ... {% endif %} #}
{% if app.user %}
<span>{{ app.user.userIdentifier }}</span>
<a href="{{ logout_path() }}">Log out</a>
{% else %}
<a href="{{ path('app_login') }}">Log in</a>
{% endif %}
</nav>Not a login check: a freshness check
is_granted() also accepts attributes that are not roles. Reach for IS_AUTHENTICATED_FULLY for anything sensitive enough that a long-lived cookie should not be sufficient, and guard the matching controller with it too.
{# templates/account/settings.html.twig #}
{% extends 'base.html.twig' %}
{% block body %}
<h1>Account settings</h1>
{# IS_AUTHENTICATED_FULLY excludes a session restored from a remember-me
cookie, so the password form is offered only after a fresh login #}
{% if is_granted('IS_AUTHENTICATED_FULLY') %}
{{ include('account/_change_password.html.twig') }}
{% else %}
<p><a href="{{ path('app_login') }}">Log in again</a> to change your password.</p>
{% endif %}
{% endblock %}
Discussion