CakePHP Authorization 3: policies, scopes and safe defaults
How to structure access control in CakePHP 5 with the Authorization 3.x plugin: entity and table policies, scopes, admin rules, request policies and tests.
Updated 7 min read

In this article
In CakePHP 5, access control belongs in policy classes run by the cakephp/authorization 3.x plugin: entity policies answer “may this user edit this record?”, table policies scope list queries to the records a user may see, and the middleware fails any request that never checked. Getting that structure right once means a forgotten check shows up in development as an exception instead of in production as a data leak.
This guide explains the architecture of the plugin as of release 3.5.3 (7 July 2026), shows how to organise policies for a typical multi-user app, and covers the defaults worth keeping. It assumes you already authenticate users with cakephp/authentication.
How the pieces fit together
Authorization is separate from authentication by design. Authentication puts an identity on the request; authorization decorates that identity with can(), canResult() and applyScope() and asks a policy resolver which policy class handles a given resource.
| Component | Job | Where it lives |
|---|---|---|
AuthorizationMiddleware | Decorates the identity; verifies each request performed or skipped a check | Application::middleware() |
AuthorizationService | Runs checks and scopes against policies | Application::getAuthorizationService() |
| Policy resolver | Maps a resource (entity, table, query, request) to a policy class | OrmResolver, MapResolver or ResolverCollection |
| Policies | Plain PHP classes with canX() and scopeX() methods | src/Policy/ |
AuthorizationComponent | Controller helpers: authorize(), can(), applyScope() | AppController::initialize() |
Install it and load the plugin:
composer require "cakephp/authorization:^3.0"
bin/cake plugin load AuthorizationIn the middleware queue, AuthorizationMiddleware goes after routing, body parsing and AuthenticationMiddleware, because it needs the identity. Then return a service from your Application class (which must implement AuthorizationServiceProviderInterface):
use Authorization\AuthorizationService;
use Authorization\AuthorizationServiceInterface;
use Authorization\Policy\OrmResolver;
use Psr\Http\Message\ServerRequestInterface;
public function getAuthorizationService(ServerRequestInterface $request): AuthorizationServiceInterface
{
return new AuthorizationService(new OrmResolver());
}OrmResolver is convention based: App\Model\Entity\Article resolves to App\Policy\ArticlePolicy, App\Model\Table\ArticlesTable to App\Policy\ArticlesTablePolicy, and a query to the policy of its repository table.

Entity policies: one decision per record
Bake generates empty policies with bin/cake bake policy --type entity Article. Each method receives the identity and the resource and must return true or a Result object; anything else counts as denied.
<?php
declare(strict_types=1);
namespace App\Policy;
use App\Model\Entity\Article;
use Authorization\IdentityInterface;
use Authorization\Policy\Result;
class ArticlePolicy
{
public function canView(?IdentityInterface $user, Article $article): bool
{
return $article->published || ($user !== null && $user->id === $article->user_id);
}
public function canUpdate(IdentityInterface $user, Article $article): Result
{
if ($user->id === $article->user_id) {
return new Result(true);
}
return new Result(false, 'not-owner');
}
public function canDelete(IdentityInterface $user, Article $article): bool
{
return $user->id === $article->user_id && !$article->published;
}
}The nullable type on canView() is deliberate. Anonymous users are passed as null; typehinting a non-nullable IdentityInterface makes the check fail automatically for guests, which is the safe default for every write action.
Table policies: scope lists, do not filter them
Per-record checks do not help on an index page. Instead, apply a scope so the database only returns rows the user may see. That also keeps pagination counts correct.
<?php
declare(strict_types=1);
namespace App\Policy;
use Authorization\IdentityInterface;
use Cake\ORM\Query\SelectQuery;
class ArticlesTablePolicy
{
public function scopeIndex(IdentityInterface $user, SelectQuery $query): SelectQuery
{
return $query->where(['Articles.user_id' => $user->getIdentifier()]);
}
}In the controller, scope the query before paginating:
public function index(): void
{
$query = $this->Authorization->applyScope($this->Articles->find());
$this->set('articles', $this->paginate($query));
}
public function edit(?string $id = null)
{
$article = $this->Articles->get($id);
$this->Authorization->authorize($article, 'update');
// patch, save and redirect as usual
}authorize() throws Authorization\Exception\ForbiddenException, whose default HTTP code is 403. Use $this->Authorization->can($article, 'update') when you need a boolean, for example to hide an Edit button. In templates, the request identity has the same can() method.
Map actions instead of renaming methods
By default the policy method is named after the controller action. If your action names and policy vocabulary differ, map them once in initialize() with $this->Authorization->mapActions(['index' => 'list', 'delete' => 'remove']), and use authorizeModel('add') to run table-level checks such as canAdd() automatically.
Admins and other cross-cutting rules
Do not repeat “if admin return true” in every method. Implement BeforePolicyInterface; its before() hook can allow (true), deny (false) or defer (null) every action of that policy.
use Authorization\IdentityInterface;
use Authorization\Policy\BeforePolicyInterface;
use Authorization\Policy\ResultInterface;
class ArticlePolicy implements BeforePolicyInterface
{
public function before(?IdentityInterface $identity, mixed $resource, string $action): ResultInterface|bool|null
{
if ($identity?->getOriginalData()->is_admin === true) {
return true;
}
return null;
}
}Note the null-safe ?->. The example in the official policies page dereferences $identity directly, which throws an error for anonymous visitors because before() also runs for them. Scopes have the equivalent BeforeScopeInterface with a beforeScope() hook.

Route-level rules with RequestAuthorizationMiddleware
Some rules are about URLs rather than records, such as “only staff may open anything under /admin”. For these, map Cake\Http\ServerRequest to a RequestPolicy implementing RequestPolicyInterface::canAccess() through a MapResolver, combine it with the ORM resolver using ResolverCollection, and add RequestAuthorizationMiddleware after AuthorizationMiddleware.
use App\Policy\RequestPolicy;
use Authorization\Policy\MapResolver;
use Authorization\Policy\OrmResolver;
use Authorization\Policy\ResolverCollection;
use Cake\Http\ServerRequest;
$map = new MapResolver();
$map->map(ServerRequest::class, RequestPolicy::class);
return new AuthorizationService(new ResolverCollection([$map, new OrmResolver()]));Treat this as a coarse outer fence. Record ownership still belongs in entity policies, because a route rule cannot see which article ID is being edited.
Keep the safety net switched on
By default AuthorizationMiddleware raises AuthorizationRequiredException after the controller runs if a request with an identity neither authorised nor explicitly skipped authorization. The docs describe this as a development aid rather than access control, but it is the cheapest way to catch a forgotten authorize() call. Leave requireAuthorizationCheck at its default and mark genuinely public actions:
$this->loadComponent('Authorization.Authorization', [
'skipAuthorization' => ['login', 'logout'],
]);Or call $this->Authorization->skipAuthorization() inside an action. For HTML apps you can turn MissingIdentityException into a login redirect with the unauthorizedHandler option (Authorization.Redirect or Authorization.CakeRedirect); leave ForbiddenException as a 403 so users get an honest error rather than a confusing redirect.
Test policies as plain classes
Policies have no framework dependencies, so unit tests are quick. Wrap a user entity in IdentityDecorator to satisfy the typehint:
use App\Model\Entity\Article;
use App\Model\Entity\User;
use App\Policy\ArticlePolicy;
use Authorization\AuthorizationService;
use Authorization\IdentityDecorator;
use Authorization\Policy\OrmResolver;
use Cake\TestSuite\TestCase;
class ArticlePolicyTest extends TestCase
{
public function testOnlyOwnerCanUpdate(): void
{
$service = new AuthorizationService(new OrmResolver());
$owner = new IdentityDecorator($service, new User(['id' => 1]));
$other = new IdentityDecorator($service, new User(['id' => 2]));
$article = new Article(['user_id' => 1]);
$policy = new ArticlePolicy();
$this->assertTrue($policy->canUpdate($owner, $article)->getStatus());
$this->assertFalse($policy->canUpdate($other, $article)->getStatus());
}
}Add one integration test per controller asserting that a second user receives a 403 on another user’s record. That test catches missing authorize() calls the unit tests cannot see.
Plugins are covered too. For a resource such as Bookmarks\Model\Entity\Bookmark, OrmResolver first looks for an application override (App\Policy\Bookmarks\BookmarkPolicy) and only then for the plugin’s own Bookmarks\Policy\BookmarkPolicy. That lets you tighten a third-party plugin’s rules without forking it. If a resolver finds no policy at all, it throws MissingPolicyException, so a missing class fails loudly instead of quietly allowing access.
Design rules that age well
- Default deny. Return booleans explicitly and avoid policies that fall through to
true. - Scope every list. Any
find()that feeds an index, export or API collection should go throughapplyScope(). - Return reasons.
Resultobjects with a reason string make logs and support tickets far easier to read. - Keep roles out of controllers. If you see
if ($user->role === ...)in a controller, move it into a policy. - Avoid stale roles. Pair this plugin with the
PrimaryKeySessionauthenticator so permission changes apply on the next request.
What to do next
- Run
bin/cake bake policy --type entityand--type tablefor each model users can modify. - Replace any hand-written ownership checks in controllers with
authorize()andapplyScope(). - Upgrade to the latest 3.5.x patch and keep the middleware’s authorization-required check enabled in every environment.
Frequently asked questions
Which Authorization plugin version supports CakePHP 5?
Version 3.x of
cakephp/authorization. The latest release in October 2026 was 3.5.3, published on 7 July 2026.What is the difference between authentication and authorization in CakePHP?
Authentication (the
cakephp/authenticationplugin) identifies the user. Authorization (cakephp/authorization) decides what that identified user may do, using policy classes.What HTTP status does a failed authorize() call return?
authorize()throwsForbiddenException, whose default code is 403. You can convert some exceptions into redirects with the middleware’sunauthorizedHandleroption.How do I restrict a paginated list to the current user’s records?
Write a
scopeIndex()method on the table policy and call$this->Authorization->applyScope($query)beforepaginate().Why do I get AuthorizationRequiredException?
The middleware found a request with an identity that never called
authorize(),applyScope()orskipAuthorization(). Add the missing check or mark the action as public.Can policies handle guests?
Yes. Guests are passed as
null. Typehint?IdentityInterfacewhere guests may be allowed, and a non-nullable type where they must always be denied.
Your next customer could start right here.
Introduce your tool, product or business to readers exploring technology.
Explore placements and request a private quote
Sources
- Authorization 3.x: Quick Start — CakePHP Book (Authorization 3.x)
- Policies — CakePHP Book (Authorization 3.x)
- AuthorizationComponent — CakePHP Book (Authorization 3.x)
- Authorization Middleware — CakePHP Book (Authorization 3.x)
- Request Authorization Middleware (docs source) — cakephp/authorization on GitHub
- Release 3.5.3 — cakephp/authorization on GitHub, 2026-07-07
Written with substantial AI assistance. Facts checked against cited sources. Editorial policy.
Comments
No comments yet. Be the first to join the conversation.
Leave a comment
Every comment is read by a moderator before it appears. Your email address is never published; it is used only to contact you about this comment.
More from Cloudsoft Zone

Industry updateCakePHP
What's new in CakePHP 5.4: ORM changes, DTO mapping and a built-in container
CakePHP 5.4 changes default eager loading, enforces entity-table ownership and adds request-to-DTO mapping, an optional built-in DI container, typed unhydrated queries and JSON streaming. Here is what to check before upgrading.
By Cloudsoft Zone

Architecture guideCakePHP
Dependency injection in CakePHP 5: services, providers and testing
How the CakePHP 5 service container works: where CakePHP injects services, when a service is worth it, shared services, configuration injection, service providers, the optional 5.4 built-in container, and swapping services in tests.
By Cloudsoft Zone

Resource guideCakePHP
CakePHP 5 production deployment checklist: security, performance and releases
A practical checklist for running CakePHP 5.4 in production: debug and secrets, App.fullBaseUrl and host-header checks, webroot as document root, HTTPS, CSRF and security headers, caching, a deploy script and monitoring.
By Cloudsoft Zone
