Skip to content

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 session and params
  • How verifies() differs from filters() and when to reach for each
  • How to extract complex authorization into a policy object

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.

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.

app/controllers/Posts.cfc
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.

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.

app/controllers/Posts.cfc
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).

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.

app/controllers/AdminUsers.cfc
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() 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.

app/controllers/Posts.cfc
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).

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.

app/lib/PostPolicy.cfc
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:

app/controllers/Posts.cfc
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.

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.

app/controllers/Posts.cfc
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.

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.