Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

ApiResource access_control

Keep on Learning!

If you liked what you've learned so far, dive in!
Subscribe to get access to this tutorial plus
video, code and script downloads.

Start your All-Access Pass
Buy just this tutorial for $12.00

With a Subscription, click any sentence in the script to jump to that part of the video!

Login Subscribe

There are two big parts to security in any app. First, how does your user authenticate? How do they log in? Honestly, that is the trickiest part... and it has really nothing to do with API Platform. We're authenticating via the json_login authenticator and a session cookie. That's a great solution for many applications. But in the bonus part 2 of the security tutorial, we'll talk about other types of applications and solutions.

Regardless of how your users authenticate, step two of security - authorization - will look the same. Authorization is all about denying access to read or perform different operations... and this is enforced in a way that's independent of how you log in. So even if the way clients of your API authenticate is much different than what we're doing, all this authorization stuff will still be relevant.

Denying access with access_control in security.yaml

When a user logs in - no matter how they authenticate or where your user data is stored - your login mechanism assigns that user a set of roles. In our app, those roles are stored in the database and we'll eventually let admin users modify them via our API. The simplest way to prevent access to an endpoint is by making sure the user has some role. And the easiest way to do that is via access_control in security.yaml.

We could, for example, say that every URL that matches the ^/api/cheeses regular expression - so anything that starts with /api/cheeses - requires ROLE_ADMIN. This is normal, boring Symfony security... and I love it!

Using access_control on your ApiResource

access_control is great for some situations, but most of the time you'll need more flexibility. In a traditional Symfony app, I typically add security to my controllers. But... in API Platform... um... we don't have any controllers! Ok, so instead of thinking about protecting each controller, we'll think about protecting each API operation. Maybe we want this collection GET operation to be accessible anonymously but we want to require a user to be authenticated in order to POST and create a new CheeseListing.

Open up that entity: src/Entity/CheeseListing.php. We already have an itemOperations key, which we used to remove the delete operation and also to customize the normalization groups of get. We can do something similar with a collectionOperations option. Start by setting this to get and post.

... lines 1 - 16
* @ApiResource(
... lines 19 - 24
* collectionOperations={
* "get",
* "post"
* },
... lines 29 - 35
* )
... lines 37 - 46
class CheeseListing
... lines 49 - 207

If we stopped here, this would change nothing. Oh... except that I have a syntax error! Silly comma! Anyways, API Platform adds two collection operations by default - get and post - so we're simply repeating what it was already doing. But now we can customize these operations.

For the post operation - that's how we create new cheese listings - we really need the user to be authenticated to do this. Set post to {} with a new access_control option inside. We're going to set this to a mini-expression: is_granted() passing that, inside single quotes ROLE_USER - that's the role that our app gives to every user.

... lines 1 - 24
* collectionOperations={
... line 26
* "post"={"access_control"="is_granted('ROLE_USER')"}
* },
... lines 29 - 207


access_control option was replaced by security in api-platform/core v2.5.0. If you're on a newer version - use it instead:

 * ...
 * @ApiResource(
 *     collectionOperations={
 *          "get",
 *          "post"={"security"="is_granted('ROLE_USER')"}
 *     },
 *     ...
 * )
 * ...
class CheeseListing
    // ...

Let's try that! The web debug toolbar tells me that I'm not logged in right now. Let's make a POST request... set the owner to /api/users/6 - that's my user id... though the value doesn't matter yet... nor do any of the others fields. Hit Execute and... perfect! A 401 error:

Full authentication is required to access this resource.

If we logged in, this would work.

Let's tighten up our itemOperations too. The PUT operations - editing a CheeseListing - is an interesting case... we definitely need the user to be logged in... but we probably also only want the "owner" of a CheeseListing to be able to edit it. Let's just handle the first part now. I'll copy my whole access_control config and paste. While we're here, let's also re-add the delete operation... but maybe only admin users can do this. Check for some ROLE_ADMIN role.

... lines 1 - 18
* itemOperations={
... lines 20 - 22
* "put"={"access_control"="is_granted('ROLE_USER')"},
* "delete"={"access_control"="is_granted('ROLE_ADMIN')"}
* },
... lines 26 - 208

Go refresh the docs... yes! The DELETE operation is back! Notice that the docs are... basically "static" as far as security is concerned: it documents the whole API, including operations that you might not have access to: it doesn't magically read your roles and hide or show different operations. That's done on purpose, but I wanted to point it out.

Try the PUT endpoint... I think I have a CheeseListing with id 1 and... just send the title field. Another 401!

Next, our security setup is about to get smarter and more complex. The goal is to make sure that only the "owner" of a CheeseListing can update that CheeseListing... and maybe also admin users. To really know that things are working, I think it's time to bootstrap a basic system to functionally test our API.

Leave a comment!

Login or Register to join the conversation
gabrielmustiere Avatar
gabrielmustiere Avatar gabrielmustiere | posted 11 months ago

it documents the whole API, including operations that you might not have access to: it doesn't magically read your roles and hide or show different operations. That's done on purpose, but I wanted to point it out.

Hi Ryan, I have some questions regarding this point, what is the best practice regarding exposing your API documentation? public entry point versus private entry point.

The entrypoints for my frontend and the entrypoints for the outside that will be potentially consumed by unknown clients who do not have to access all my entry points.

I don't know if it's clear.


1 Reply

Hey gabrielmustiere

You can customize your API docs so it can fit your business needs. This video may give you some ideas: https://symfonycasts.com/sc...

If you don't want to overcomplicate things and you can keep your API endpoints secure enough, you can stick to only one API docs page and show all endpoints there


1 Reply
Jakub Avatar
Jakub Avatar Jakub | posted 2 months ago | edited

I have a problem with securing post operation on cheeses. I've put that code: "post"={"security"="is_granted('ROLE_USER')"} into collectionOperations but when I try to post a new cheeselisting item it works. I mean it responses with code 201 instead of 401. I'm confused, maybe because I'm a begginer.
I'm using Symfony 4.4 and API Platform 2.7.
I don't know what should I do to make security working.
I would be grateful if someone could help me.

Jakub Avatar

Sorry for that post. I've cleared the cache and security started to work properly (I mean I received 401 code when I've tried to post new cheeselisting). As I said I'm a begginer.


Hey Jakub,

No problem at all! And I'm super happy you figured the problem out yourself, well done! Clearing the cache is the easiest win ;)


Mykyta Avatar

Going through this tutorial right now, but with the new versions like php 8.1, api platform 2.6 and noticed that I have the same code like you do, but there is no description field, seems like normalization groups are not working properly...

Mykyta Avatar

So I checkout the latest version, when we added the groups, serialized name to the text description and I didn't see the description field in the post request, I'm pretty sure that I did...


Hey @Mykyta!

This stuff can be tricky - especially with slight changes in the new versions of API Platform. API Platform 2.6 "should" still work like the tutorial, but sometimes there are subtle changes.

Anyways, I wasn't quite sure: did you get this working in the end? Or is the description field still missing?


1 Reply
Mykyta Avatar

Hey @weaverryan!

Thanks for replying. Yes, It was fixed by updating the symfony/serializer package from v6.1.3 to v6.1.4 :)


1 Reply

Wow - great job figuring that out - super happy it's working!

1 Reply
Simon L. Avatar
Simon L. Avatar Simon L. | posted 2 years ago

Hi !

I am facing an issue from yesterday, and I really start to desperate...

I want to use a voter on a post operation, so I wrote this :

* @ApiResource(
* attributes={
* "security" = "is_granted('ROLE_ADMIN')",
* },
* collectionOperations={
* "get" = { "security" = "is_granted('ROLE_ADMIN')" },
* "post" = {
* "security_post_denormalize" = "is_granted('USER_CREATE', object)",
* "validation_groups" = { "Default", "create" },
* },
* },

I did understand that I have to use "security_post_denormalize" instead of "security' because the object has to be first denormalize in order to exist, and then to be used in my Voter.

But it just doesn't work...

If I use "security" instead of "security_post_denormalize", it works fine, but then I can't use the object in the Voter anymore.

I even tried to use YAML instead of annotations, thinking that maybe Annotations is not currectly taking into account "security_post_denormalize". But that changed nothing.

Is there any kind of known bug about "security_post_denormalize", or am I doing some stupid mistake somewhere?


Hey Simon L.!

Hmm, let's see what we can figure out :). From checking the core code, I *would* expect this to work. Here is the flow internally:

A) The DeserializeListener is called, which deserializes your object and puts it on "data" key of $request->attributes - https://github.com/api-plat...

B) The DenyAccessListener::onSecurityPostDenormalize is called - https://github.com/api-plat...

If you follow the logic, it uses the data key as the object variable in the expression: https://github.com/api-plat...

So... I can't see what you're doing wrong. The first question is: is your custom voter being called at *all*. It seems like you think the answer to this is "no", but let's verify :). The best way to do that is to make an API request, then go to /_profiler in your browser, and find the profiler for the request you just made - e.g. like we do here - https://symfonycasts.com/sc... On the security section on the left, if you scroll down, there is an "Access Decision Log". You SHOULD see that a USER_CREATE "decision" was considered by the voters. If you don't see it, then we'll know that (for some reason) ApiPlatform is not executing your expression. If you DO see it, then we'll know that there is a problem with the voter itself.

I hope this helps you to debug - let me know what you find out!


Simon L. Avatar
Simon L. Avatar Simon L. | weaverryan | posted 2 years ago | edited

Hello weaverryan :)

So I did what you said, and the voter is executed.

I dug further, and I found out that it seems there is a problem with the $attribute parameter in the voter.

dd($attribute) with security : "USER_CREATE" (normal)

dd($attribute) with security_post_denormalize : "ROLE_ADMIN" (not normal !!!)

It's like the $aatribute is not reading USER_CREATE in "security_post_denormalize" = "is_granted('USER_CREATE', object)" anymore...


Hey Simon L.!

Good digging! I might have an explanation :).

You have security" = "is_granted('ROLE_ADMIN')" on the top-level of your annotations, and then you have security_post_denormalize under your operation-specific configuration.

That config is merged together for your post operation. The result is that you effectively have this for your post operation:

security" = "is_granted('ROLE_ADMIN')",
"security_post_denormalize" = "is_granted('USER_CREATE', object)",

The result is that the security system will be called twice: the first security attribute will be run first (right before deserialization) and then (assuming you haven't already been denied access), the second security_post_denormalize will be executed right after deserialization.

So my guess is that you're getting denied access by that ROLE_ADMIN and that you do NOT want that to be executed at all. If so, just override this inside your post operation. Set it to: security" = "is_granted('IS_AUTHENTICATED_ANONYMOUSLY')", that should effectively remove that check :).

Let me know if that helps!


1 Reply
Simon L. Avatar
Simon L. Avatar Simon L. | weaverryan | posted 2 years ago | edited

Hello weaverryan

You are right, it seems that the secutiry check is first run for ROLE_ADMIN, and then somehow, the attribute is still ROLE_ADMIN, instead of USER_CREATE.

So I simply removed the first security check on top (and I left the security checks for each operation POST, item GET, PUT, PATCH and DELETE). I guess it is secure enough like this.

Maybe "security_post_denormalize" should be improved in the future?

Anyway, I thank you for your sincere help :)


Hey Simon L.!

> You are right, it seems that the secutiry check is first run for ROLE_ADMIN, and then somehow, the attribute is still ROLE_ADMIN, instead of USER_CREATE.

Yep! It's really that there are *two* security checks for each operation: security and then also security_post_denormalize. If both are set, then you have two security checks. And the top-level attributes serve as default values for each operation.

> So I simply removed the first security check on top (and I left the security checks for each operation POST, item GET, PUT, PATCH and DELETE). I guess it is secure enough like this.

Hopefully ;). If you *also* need to check ROLE_ADMIN for any of the other operations, you could re-add it under those operations.

> Maybe "security_post_denormalize" should be improved in the future?

I'm not sure I see the problem with it. But if you do see a problem, definitely I would like to know :). I was involved in the feedback that caused security and security_post_denormalize to be created.


1 Reply
Simon L. Avatar
Simon L. Avatar Simon L. | weaverryan | posted 2 years ago | edited

I see 3 main problems with this:

1. If someone doesn't know this, he will just think that "security_post_denormalize" doesn't work. And trust me, I've looked everywhere on the Internet during many hours, just to end up with nothing, and then I asked the question here. If people don't give up or don't know you, they may start building a tricky solution by themselves (as I started to do myself with a validator).

2. Even if you know this particular behaviour, it's annoying that we must set up every security check if we only want a global security and then a special case for POST.

3. Even we do set a security check for all operations, we feel as there is something wrong. The $attribute shoudn't silently change. Moreover settings on operation level should prevail on global ones.

That being said, I am not expecting you to change that or anyone else, I would love to help Symfony and API Platform by doing it myself if needed, but I still have lot to learn before contributing to the community :)

I think at least that this behaviour should be mentioned here: https://api-platform.com/do...

Notice that in the example, security is used on top level and security_post_denormalize on the POST operation level:

* @ApiResource(
* attributes={"security"="is_granted('ROLE_USER')"},
* collectionOperations={
* "get",
* "post" = { "security_post_denormalize" = "is_granted('BOOK_CREATE', object)" }
* },
* itemOperations={
* "get" = { "security" = "is_granted('BOOK_READ', object)" },
* "put" = { "security" = "is_granted('BOOK_EDIT', object)" },
* "delete" = { "security" = "is_granted('BOOK_DELETE', object)" }
* },
* )

Thanks weaverryan !

Have a great day !

1 Reply

Hey Simon L.!

Thanks for sharing this :). At the *very* least, you are 100% correct that the example should mention this - it's a *perfect* example to explain what's going on. If you'd like to contribute, that's a pretty good spot - a tiny explanation after both code blocks would help - something explaining the "flow" that security will still be called first, and then (if access wasn't already denied) your security_post_denormalize will call the voter. That document is on GitHub here - https://github.com/api-plat... - if you do find time to make a pull request, definitely let me know and I can review and +1 it.

Also, I'm not sure if it works (you could try it... I *think* it doesn't), but it would be nice if we could say "security"=false in the annotations to disable security. This would allow you to keep "security"="is_granted('ROLE_USER')" on the top-level, but disable it on an operation. I mentioned a way to do that earlier with IS_AUTHENTICATED_ANONYMOUSLY... but =false would be much nicer. If that doesn't currently work, that might also be a nice, small feature to help things.


Simon L. Avatar

Sure I did it: https://github.com/api-plat...

About your suggestion, I am now trying to solve an issue with deserialization, and after I'll try it :)


Woohoo! And merged! You rock!

erop Avatar

Am I right saying that one must leave security.firewalls.main.anonymous: true in config/packages/security.yaml and protect API endpoints with operation-level 'access_control' to expose Swagger UI and make it available for browsing, etc. ? Wouldn't it be more reasonable to protect whole path (eg. '^/api') with global security.firewalls.main.anonymous: false and have a setting for explicitly exposing Swagger UI ? BTW, I tried to expose Swagger UI with 'access_control' in security.yaml with 'roles': IS_AUTHENTICATED_ANONYMOUSLY, methods: [GET] but it didn't work. Are there any best practices to expose Swagger UI on the same path as API endpoints are?


Hey erop!

Ah, excellent question! So basically, both options are "valid": (A) setting anonymous: true and then protecting access with access_control (either in the ApiResource annotation or also inside security.yaml itself) OR (B) setting anonymous: false. But let me explain a bit further :).

The preferred approach is always to use anonymous: true, even if you can get both "options" to work. Why? It's more of a philosophical reason: the purpose of the firewall is authentication (i.e. identifying the user) *not* authorization (blocking access). So we typically prefer to only "identity" the user in the firewall (even if we identity them as anonymous) and then block access elsewhere. But, btw, if you DO want to protect *large* areas of your API easily, using access_control in security.yaml is an easy way to do that. You could even do this:

- { path: ^/api$, roles: IS_AUTHENTICATED_ANONYMOUSLY }
- { path: ^/api, roles: ROLE_USER }

That would require login for any URLs starting with /api, but WOULD allow access to exactly /api. You might need to tweak that a bit to be perfect (e.g. there are some "docs" URLs that you might also want public), but I think you get the idea ;).

But, there is one more part to your question. Even if anonymous: true is the preferred approach, why didn't your attempt to set anonymous: false and then use access_control to *allow* anonymous access to the Swagger UI work? The reason is that, on each request, Symfony matches & activates exactly ONE firewall. If you have anonymous: false on it, the user is immediately denied access. Only *after* the pass through the firewall are access_control checked. So, *if* you wanted to do the anonymous: false, you need to use *two* firewalls:

# again, you might need to play with this to get the exact right URLs
pattern: ^/api$
anonymous: true
# no pattern here, so it will match all requests *except* those that match the first firewall.
anonymous: false

That's not my recommended approach, but hopefully it helps you understand! With this, requests to the Swagger UI will match the first firewall (and only the first, because only one firewall is ever active per request, and it matches from top to bottom like routing) and anonymous *will* be allowed.


erop Avatar

Thanks, Ryan! I like your "philosophical reason" a lot! :) Moreover it seems quite reasonable to set operation-level 'access_control' while developing @ApiResource annotation.

Stefan T. Avatar
Stefan T. Avatar Stefan T. | posted 3 years ago

This annotation looks awfull 😥


Hey Stefan T.!

Yea... I kind of agree. I recently opened an issue on API Platform where I recommended adding something that would look more like access_control="ROLE_USER", but I'm not sure if it will go anywhere. The problem is that using the "expression language" (the way it is now) is useful in some cases. But when you don't need it, it's ugly.

We'll see what happens.


Cat in space

"Houston: no signs of life"
Start the conversation!

This tutorial works great for Symfony 5 and API Platform 2.5/2.6.

What PHP libraries does this tutorial use?

// composer.json
    "require": {
        "php": "^7.1.3, <8.0",
        "ext-ctype": "*",
        "ext-iconv": "*",
        "api-platform/core": "^2.1", // v2.4.5
        "composer/package-versions-deprecated": "^1.11", // 1.11.99
        "doctrine/annotations": "^1.0", // 1.13.2
        "doctrine/doctrine-bundle": "^1.6", // 1.11.2
        "doctrine/doctrine-migrations-bundle": "^2.0", // v2.0.0
        "doctrine/orm": "^2.4.5", // v2.7.2
        "nelmio/cors-bundle": "^1.5", // 1.5.6
        "nesbot/carbon": "^2.17", // 2.21.3
        "phpdocumentor/reflection-docblock": "^3.0 || ^4.0", // 4.3.1
        "symfony/asset": "4.3.*", // v4.3.2
        "symfony/console": "4.3.*", // v4.3.2
        "symfony/dotenv": "4.3.*", // v4.3.2
        "symfony/expression-language": "4.3.*", // v4.3.2
        "symfony/flex": "^1.1", // v1.18.7
        "symfony/framework-bundle": "4.3.*", // v4.3.2
        "symfony/http-client": "4.3.*", // v4.3.3
        "symfony/monolog-bundle": "^3.4", // v3.4.0
        "symfony/security-bundle": "4.3.*", // v4.3.2
        "symfony/twig-bundle": "4.3.*", // v4.3.2
        "symfony/validator": "4.3.*", // v4.3.2
        "symfony/webpack-encore-bundle": "^1.6", // v1.6.2
        "symfony/yaml": "4.3.*" // v4.3.2
    "require-dev": {
        "hautelook/alice-bundle": "^2.5", // 2.7.3
        "symfony/browser-kit": "4.3.*", // v4.3.3
        "symfony/css-selector": "4.3.*", // v4.3.3
        "symfony/maker-bundle": "^1.11", // v1.12.0
        "symfony/phpunit-bridge": "^4.3", // v4.3.3
        "symfony/stopwatch": "4.3.*", // v4.3.2
        "symfony/web-profiler-bundle": "4.3.*" // v4.3.2