Securing It

A firewall for who the visitor is, a voter for whether they may touch this row, and the hardening pass that follows.

Syntax#[IsGranted('ARTICLE_EDIT', 'article')]

Securing the capstone answers two separate questions. Authentication: who is this visitor. Authorization: may they do this. The firewall answers the first. It is the configuration block that decides, for a matched set of URLs, how a visitor proves identity and how that identity is carried between requests. A voter answers the second. It is a small class the framework asks "may this user do X to this object", and which replies yes, no, or abstain.

Roles cannot express ownership

ROLE_AUTHOR says a user may write articles. It cannot say they may edit this article, because ownership is a fact about the row, not about the user. So the capstone uses two levels. Access control in security.yaml keeps anonymous visitors out of /articles/new. A voter decides each edit, comparing the article's author with the logged-in user and letting an editor through regardless.

In a controller, #[IsGranted('ARTICLE_EDIT', 'article')] names the attribute and the controller argument holding the subject. The framework calls every voter that supports that pair.

The hardening pass

Password hashing set to auto. Access control that denies by default, with PUBLIC_ACCESS written only where you mean it. Session cookies HTTP-only, secure, and SameSite lax. CSRF protection on every form that changes state. No user-supplied value passed through Twig's |raw.

What trips people up

Returning false from supports() is an abstain, not a deny. With the default affirmative strategy, a misspelled attribute string means no voter supports it, every voter abstains, and access is refused - which looks exactly like a working deny.

Example

Example · php
<?php
// src/Security/Voter/ArticleVoter.php

declare(strict_types=1);

namespace App\Security\Voter;

use App\Entity\Article;
use App\Entity\User;
use App\Security\Role;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Vote;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;

final class ArticleVoter extends Voter
{
    public const string EDIT = 'ARTICLE_EDIT';
    public const string PUBLISH = 'ARTICLE_PUBLISH';

    protected function supports(string $attribute, mixed $subject): bool
    {
        return \in_array($attribute, [self::EDIT, self::PUBLISH], true)
            && $subject instanceof Article;
    }

    protected function voteOnAttribute(
        string $attribute,
        mixed $subject,
        TokenInterface $token,
        ?Vote $vote = null,
    ): bool {
        $user = $token->getUser();

        if (!$user instanceof User) {
            // The reason is shown on the exception page and in the logs.
            $vote?->addReason('No authenticated user.');

            return false;
        }

        // Editors may do both things to any article.
        if (\in_array(Role::Editor->value, $user->getRoles(), true)) {
            return true;
        }

        if (self::PUBLISH === $attribute) {
            $vote?->addReason('Only editors may publish.');

            return false;
        }

        // Everyone else may edit only their own work.
        return $subject->getAuthor() === $user;
    }
}

// Asked ARTICLE_EDIT with the author logged in: granted.
// Asked ARTICLE_EDIT with another author logged in: denied, 403.
// Asked ARTICLE_PUBLISH without ROLE_EDITOR: denied, 403.

When to use it

  • Stop one author from editing another author's article without writing the check in every controller
  • Turn a vague 'admins can do more' requirement into roles plus one voter
  • Run a pre-launch pass over cookies, CSRF and access rules

More examples

The firewall and the coarse rules

access_control is evaluated top to bottom and the first matching path wins, so the public entries come before the broad ones. These rules work on URLs only; anything that depends on which record is being touched has to be a voter. Write this file once per application and then leave it alone.

Example · yaml
# config/packages/security.yaml
security:
    password_hashers:
        Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto'

    providers:
        app_user_provider:
            entity:
                class: App\Entity\User
                property: email

    firewalls:
        dev:
            pattern: ^/(_profiler|_wdt)/
            security: false
        main:
            lazy: true
            provider: app_user_provider
            form_login:
                login_path: app_login
                check_path: app_login
            logout:
                path: app_logout

    access_control:
        - { path: '^/login$', roles: PUBLIC_ACCESS }
        - { path: '^/articles$', roles: PUBLIC_ACCESS }
        - { path: '^/api/articles/\d+$', roles: PUBLIC_ACCESS, methods: [GET] }
        - { path: '^/articles', roles: ROLE_AUTHOR }
        - { path: '^/api', roles: ROLE_USER }

    role_hierarchy:
        ROLE_AUTHOR: ROLE_USER
        ROLE_EDITOR: [ROLE_AUTHOR, ROLE_USER]

Using the voter from a controller

The attribute runs before the method body, so the controller never contains an ownership branch. Inside a template the same check reads {% if is_granted('ARTICLE_EDIT', article) %}, which is how you hide an edit link the user cannot follow. Reach for the attribute form whenever the whole action is gated; use the longhand $this->denyAccessUnlessGranted() when only part of the method is.

Example · php
<?php
// src/Controller/ArticleController.php (excerpt)

declare(strict_types=1);

namespace App\Controller;

use App\Entity\Article;
use App\Form\ArticleForm;
use App\Security\Voter\ArticleVoter;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;

final class ArticleController extends AbstractController
{
    // 'article' is the name of the argument below, not a string literal
    // the voter receives: the resolved Article object is the subject.
    #[Route('/articles/{id}/edit', name: 'app_article_edit', methods: ['GET', 'POST'])]
    #[IsGranted(ArticleVoter::EDIT, 'article')]
    public function edit(
        Article $article,
        Request $request,
        EntityManagerInterface $entityManager,
    ): Response {
        $form = $this->createForm(ArticleForm::class, $article);
        $form->handleRequest($request);

        if ($form->isSubmitted() && $form->isValid()) {
            // The article is already managed, so flush() alone persists it.
            $entityManager->flush();

            return $this->redirectToRoute('app_article_index');
        }

        return $this->render('article/edit.html.twig', ['form' => $form]);
    }
}

// GET /articles/7/edit as the author returns 200 with the edit form.
// The same request as a different author returns 403.

The hardening settings

The first block is the cookie hardening; the second matters once the application sits behind an ingress or load balancer, because without it Symfony sees the proxy's address and scheme and generates http:// URLs for an https:// site. Apply both before the first deployment, not after someone reports a redirect loop.

Example · yaml
# config/packages/framework.yaml
framework:
    csrf_protection: true
    session:
        cookie_httponly: true
        cookie_samesite: lax
        # 'auto' sends the Secure flag whenever the request is HTTPS,
        # so the same config works on a local plain-HTTP server.
        cookie_secure: auto

# config/packages/prod/framework.yaml
framework:
    trusted_proxies: '%env(TRUSTED_PROXIES)%'
    trusted_headers: ['x-forwarded-for', 'x-forwarded-proto', 'x-forwarded-port']

Discussion

  • Be the first to comment on this lesson.