Digging Deeper
Authorization & Filters
This page shows you how to use controller filters to enforce access rules. You’ll wire an authentication gate, reject edits to records the current user doesn’t own, restrict actions to admin roles, guard against malformed params with verifies(), and extract authorization into a reusable policy object once the rules outgrow a single filter.
You’ll learn:
- How to compose filters that deny access before an action runs
- How to write ownership and role-based rules using
sessionandparams - How
verifies()differs fromfilters()and when to reach for each - How to extract complex authorization into a policy object
The filter API — recap
Section titled “The filter API — recap”Filters register in config() with filters(through="methodName", only="...", except="...", type="before"). The through method must exist on the controller and must be declared private — a public filter becomes a routable action. Call redirectTo() or renderText() inside the filter to short-circuit the request; the action never runs and no further before-filters fire.
For the full argument list, placement semantics, and a run-through of the order of operations, see Controllers and Actions. This page focuses on what to put inside the filter.
Authentication gate
Section titled “Authentication gate”The canonical pattern: deny access to anything except the public read actions. A logged-in user has a principal in the session; a guest gets redirected to the login page.
component extends="Controller" { function config() { super.config(); filters(through="authenticate", except="index,show"); }
function index() { posts = model("Post").findAll(order="publishedAt DESC"); }
function show() { post = model("Post").findByKey(params.key); }
function edit() { post = model("Post").findByKey(params.key); }
private function authenticate() { if (!StructKeyExists(session, "userId")) { flashInsert(error="Please log in to continue."); redirectTo(route="login"); } }}except="index,show" leaves the two read actions public. Every other action — new, create, edit, update, delete — triggers authenticate before the action body runs. The filter returns void; short-circuit is signalled by calling redirectTo.
Ownership checks
Section titled “Ownership checks”Authentication proves who you are. Authorization decides whether you’re allowed to touch this record. Layer a second filter after authenticate that rejects edits on posts owned by somebody else.
component extends="Controller" { function config() { super.config(); filters(through="authenticate", except="index,show"); filters(through="ownershipCheck", only="edit,update,delete"); }
function edit() { post = params.post; }
function update() { post = params.post; if (post.update(params.postData)) { flashInsert(success="Post updated."); redirectTo(route="post", key=post.id); } else { renderView(action="edit"); } }
private function ownershipCheck() { if (params.post.userId != session.userId) { flashInsert(error="That post isn't yours."); redirectTo(route="posts"); } }}params.post is a model instance populated by route model binding, not a raw struct. Without binding, params.post doesn’t exist — on screen you get an undefined-variable error, and Wheels writes a one-time hint per controller + action to wheels.log (in any environment except production) telling you to enable binding. See Route Model Binding for the one-line fix (binding=true on the resource).
Role-based rules
Section titled “Role-based rules”For admin-only actions, read the current user’s role and reject the rest. The tidy version uses a dedicated filter and a finder — keep the query off the hot path of every request by scoping it to admin-gated actions only.
component extends="Controller" { function config() { super.config(); filters(through="authenticate"); filters(through="requireAdmin"); }
function index() { users = model("User").findAll(order="createdAt DESC"); }
private function requireAdmin() { user = model("User").findByKey(session.userId); if (!IsObject(user) || user.role != "admin") { flashInsert(error="Admins only."); redirectTo(route="posts"); } }}The flat AdminUsers.cfc is reachable at /adminusers through the default wildcard route. A nested app/controllers/Admin/Users.cfc only works if you add a route that targets it (the default route set has none — /admin/users parses as controller admin, action users and 404s).
On the model side, keep role predicates out of ad-hoc WHERE strings. Use a named scope or the query builder — see Query Builder and Scopes for model("User").where("role", "admin").get() and the scope(name="admins", where="role = 'admin'") shorthand.
verifies() — type and presence guards
Section titled “verifies() — type and presence guards”verifies() runs before filters() and is built for one job: reject requests that are missing required params or have the wrong shape. It’s the first line of defence against URL tampering (/posts/abc/edit where the key should be an integer) and missing form fields.
component extends="Controller" { function config() { super.config(); verifies( only="show,edit,update,delete", params="key", paramsTypes="integer", handler="invalidRequest" );
filters(through="authenticate", except="index,show"); }
function show() { post = model("Post").findByKey(params.key); }
private function invalidRequest() { flashInsert(error="That request didn't look right."); redirectTo(route="posts"); }}Arguments match the framework signature: params is a comma-list of expected keys, paramsTypes is a parallel list of types passed through to IsValid() (integer, numeric, email, uuid, boolean, date, string). handler names a private method that runs on failure. Omit handler and pass redirect arguments (e.g. action="index") to redirect on failure instead; omit both and a failed verification aborts the request outright — the client receives a blank 200 response, not an error status. Session and cookie keys have their own argument pairs (session/sessionTypes, cookie/cookieTypes).
Policy objects
Section titled “Policy objects”When the authorization rules for a resource grow past two conditions, move them out of the filter and into a small component under app/lib/. The filter becomes a thin delegation; the rules become unit-testable in isolation.
component { public boolean function canEdit(required struct user, required any post) { if (arguments.user.role == "admin") { return true; } return arguments.post.userId == arguments.user.id; }
public boolean function canDelete(required struct user, required any post) { return arguments.user.role == "admin"; }}The controller filter asks the policy the question and redirects on a false answer:
component extends="Controller" { function config() { super.config(); filters(through="authenticate", except="index,show"); filters(through="authorizeEdit", only="edit,update"); filters(through="authorizeDelete", only="delete"); }
private function authorizeEdit() { policy = new app.lib.PostPolicy(); currentUser = { id: session.userId, role: session.userRole }; if (!policy.canEdit(user=currentUser, post=params.post)) { flashInsert(error="Not allowed."); redirectTo(route="posts"); } }
private function authorizeDelete() { policy = new app.lib.PostPolicy(); currentUser = { id: session.userId, role: session.userRole }; if (!policy.canDelete(user=currentUser, post=params.post)) { flashInsert(error="Not allowed."); redirectTo(route="posts"); } }}Register PostPolicy in the DI container so controllers resolve it by name instead of new-ing it every request — see The Dependency Injection Container for di.map("postPolicy").to("app.lib.PostPolicy").asSingleton() and the matching inject("postPolicy") declaration.
Filter order
Section titled “Filter order”Before-filters run in the order they register. After-filters also run in the order they register. Register authentication first, then authorization, then data loading — each layer depends on the previous one succeeding.
component extends="Controller" { function config() { super.config(); filters(through="authenticate", except="index,show"); filters(through="ownershipCheck", only="edit,update,delete"); filters(through="loadCategories", only="new,edit"); }
private function authenticate() { if (!StructKeyExists(session, "userId")) { redirectTo(route="login"); } }
private function ownershipCheck() { if (params.post.userId != session.userId) { redirectTo(route="posts"); } }
private function loadCategories() { categories = model("Category").findAll(order="name ASC"); }}If authenticate redirects, the chain stops — ownershipCheck and loadCategories never run, and the action body never runs. The early-exit rule is what keeps each filter small: each one can assume everything before it has already passed.
Testing filters
Section titled “Testing filters”Filter methods are private, so you can’t call them directly from a spec. Exercise them through the controller action and assert on the response — a redirect when access is denied, the rendered view when it’s allowed. The Controller Tests guide walks through the full pattern using the TestClient — a real HTTP client with chainable assertions.