Skip to main content

Cloudsoft Zone Build, deploy and scale with practical guides to PHP, Node.js, React, DevOps and AI automation. Explore the guides

Cloudsoft Zone
CakePHPDevelopmentPHPArchitecture guide

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.

Cloudsoft Zone

Updated 7 min read

Wall-mounted access terminal with a keypad, a small screen and a glowing green fingerprint reader
Photograph: Aliva Sahoo / CC BY-SA 4.0, via Wikimedia Commons
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.

ComponentJobWhere it lives
AuthorizationMiddlewareDecorates the identity; verifies each request performed or skipped a checkApplication::middleware()
AuthorizationServiceRuns checks and scopes against policiesApplication::getAuthorizationService()
Policy resolverMaps a resource (entity, table, query, request) to a policy classOrmResolver, MapResolver or ResolverCollection
PoliciesPlain PHP classes with canX() and scopeX() methodssrc/Policy/
AuthorizationComponentController helpers: authorize(), can(), applyScope()AppController::initialize()

Install it and load the plugin:

Shell
composer require "cakephp/authorization:^3.0"
bin/cake plugin load Authorization

In 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):

PHP
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.

A bunch of different door keys on several key rings lying on a dark cloth
One identity, many permissions: each policy method is a separate key. Photo: Pittigrilli, CC BY-SA 4.0.

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
<?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
<?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:

PHP
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.

PHP
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.

Finger pressed on the fingerprint sensor of a wall-mounted access terminal with a touch screen
A single checkpoint can apply blanket rules, just as a before() hook does for a policy. Photo: DarienLizard, CC BY-SA 4.0.

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.

PHP
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:

PHP
$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:

PHP
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 through applyScope().
  • Return reasons. Result objects 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 PrimaryKeySession authenticator so permission changes apply on the next request.

What to do next

  1. Run bin/cake bake policy --type entity and --type table for each model users can modify.
  2. Replace any hand-written ownership checks in controllers with authorize() and applyScope().
  3. 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/authentication plugin) 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() throws ForbiddenException, whose default code is 403. You can convert some exceptions into redirects with the middleware’s unauthorizedHandler option.

  • 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) before paginate().

  • Why do I get AuthorizationRequiredException?

    The middleware found a request with an identity that never called authorize(), applyScope() or skipAuthorization(). Add the missing check or mark the action as public.

  • Can policies handle guests?

    Yes. Guests are passed as null. Typehint ?IdentityInterface where guests may be allowed, and a non-nullable type where they must always be denied.

Advertise on CloudSoftZone

Your next customer could start right here.

Introduce your tool, product or business to readers exploring technology.

Advertise with us

Explore placements and request a private quote

Sources

  1. Authorization 3.x: Quick Start — CakePHP Book (Authorization 3.x)
  2. Policies — CakePHP Book (Authorization 3.x)
  3. AuthorizationComponent — CakePHP Book (Authorization 3.x)
  4. Authorization Middleware — CakePHP Book (Authorization 3.x)
  5. Request Authorization Middleware (docs source) — cakephp/authorization on GitHub
  6. 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.

Never published. Used only to contact you about this comment.

Loading the security check…

Comments are moderated before publication.