Blog

Luis Majano

September 24, 2026

Spread the word


Share your thoughts

Welcome to the ColdBox 8.2 Deep Dive, a five-part series that takes each major feature of ColdBox 8.2.0 and goes well past the release notes: real code, real patterns, and the gotchas you only learn after shipping.

The series:

  1. Route-Scoped Middleware (you are here)
  2. HTTP Caching Done Right
  3. Server-Sent Events You Can Test
  4. AI Routing and Gateways
  5. Capstone: Building a Live Support Desk

We start with the feature that quietly changes how you structure every ColdBox application.

The Problem With Global Interceptors

Interceptors are one of ColdBox's oldest superpowers. They're also a blunt instrument when a concern only applies to some routes.

Here's a pattern you've probably written, or inherited:

// interceptors/Security.bx
class {
    function preProcess( event, data, buffer, rc, prc ){
        var path = event.getCurrentRoutedURL();
        if ( path.startsWith( "admin/" ) && !auth.isLoggedIn() ) {
            event.relocate( "login" );
        }
        if ( path.startsWith( "api/" ) && !apiKeys.isValid( event.getHTTPHeader( "X-Api-Key", "" ) ) ) {
            // ...
        }
    }
}

It works, until it doesn't. The security rules live far from the routes they protect, every request pays for every if, and the answer to "what runs when someone hits /admin/users?" requires reading code in three places.

Route-scoped middleware moves that answer into the one file that already describes your URLs: the router.

The Basics

.middleware() attaches work to a route. It runs at a ColdBox interception point, preProcess by default:

// config/Router.bx
route( "/admin/:action" )
    .middleware( ( event, rc, prc ) => {
        if ( !auth.isLoggedIn() ) {
            event.relocate( "login" );
            return true;
        }
    } )
    .toHandler( "admin" );

Every target receives event, rc, and prc. Returning true short-circuits the rest of this route's middleware at that point.

There's no new subsystem to learn here. Under the hood, route middleware reuses the exact interception dispatch ColdBox interceptors already use, just scoped to the matched route.

Targets: Closures, Classes, Instances

A target can be:

  • a closure or lambda, great for prototyping and one-offs
  • a WireBox ID, resolved on every request, so the mapping's scope is respected
  • a live object instance
  • a middlewareGroup() name (more on that below)
  • an array of any mix of the above

For anything you'll reuse, write a class. It needs no base class and no interface, only a method named after the interception point:

// models/middleware/RequireLogin.bx
class singleton {

    property name="auth"  inject="AuthService";
    property name="flash" inject="coldbox:flash";

    function preProcess( event, rc, prc ){
        if ( !auth.isLoggedIn() ) {
            flash.put( "returnTo", event.getCurrentRoutedURL() );
            event.relocate( "login" );
            return true;
        }
        prc.currentUser = auth.getUser();
    }

}
// config/Router.bx
route( "/account/:action" ).middleware( "RequireLogin" ).toHandler( "account" );

Because it's a WireBox object you get injection, AOP, and, as we'll see, testability for free. Notice the middleware also enriches the request by putting currentUser into prc. Middleware isn't only a gate; it's a great place to prepare context your handlers need.

Configure Middleware Per Route With Meta

What about middleware that needs a parameter, like a required role? Routes already carry arbitrary metadata via .meta(), and middleware can read it from the current route record:

// models/middleware/RequireRole.bx
class singleton {

    property name="auth" inject="AuthService";

    function preProcess( event, rc, prc ){
        var required = event.getCurrentRouteMeta().role ?: "";
        if ( len( required ) && !auth.getUser().hasRole( required ) ) {
            event
                .renderData( type : "json", data : { "error" : "Forbidden" }, statusCode : 403 )
                .noExecution();
            return true;
        }
    }

}
// config/Router.bx
route( "/admin/billing" )
    .meta( { role : "finance" } )
    .middleware( [ "RequireLogin", "RequireRole" ] )
    .toHandler( "admin.billing" );

route( "/admin/users" )
    .meta( { role : "admin" } )
    .middleware( [ "RequireLogin", "RequireRole" ] )
    .toHandler( "admin.users" );

One class, configured declaratively per route. The route file reads like a security policy.

Before and After the Handler

The second argument selects the interception point. postProcess runs after your handler, which makes it the natural home for auditing and response shaping:

// models/middleware/AuditLog.bx
class singleton {

    property name="log" inject="logbox:logger: audit";

    function postProcess( event, rc, prc ){
        log.info(
            "#prc.currentUser.getEmail() ?: 'anonymous'# #event.getHTTPMethod()# #event.getCurrentRoutedURL()#"
        );
    }

}
route( "/admin/users/:id" )
    .middleware( [ "RequireLogin", "RequireRole" ] )
    .middleware( "AuditLog", "postProcess" )
    .toHandler( "admin.users" );

You can call .middleware() as many times as you like. Entries accumulate in order.

Where it runs in the lifecycle

Route middleware runs after the global preProcess announcement and before the global postProcess announcement:

global preProcess interceptors
  → route middleware (preProcess)
    → handler action
  → route middleware (postProcess)
global postProcess interceptors

Global interceptors stay the outermost layer. Route-specific work runs closest to the handler, which is exactly where it belongs.

A Real Example: A Rate Limiter

Let's write something with actual moving parts. A per-key rate limiter backed by CacheBox:

// models/middleware/RateLimiter.bx
class singleton {

    property name="cache" inject="cachebox:default";

    variables.maxPerMinute = 60;

    function preProcess( event, rc, prc ){
        var client = event.getHTTPHeader( "X-Api-Key", cgi.REMOTE_ADDR );
        var bucket = "ratelimit-#client#-#dateTimeFormat( now(), 'yyyyMMddHHnn' )#";
        var hits   = cache.getOrSet( bucket, () => 0, 1 ) + 1;
        cache.set( bucket, hits, 1 );

        event.setHTTPHeader( name : "X-RateLimit-Remaining", value : max( 0, maxPerMinute - hits ) );

        if ( hits > maxPerMinute ) {
            event
                .renderData( type : "json", data : { "error" : "Too many requests" }, statusCode : 429 )
                .noExecution();
            return true;
        }
    }

}

This is a simple fixed-window counter to illustrate the pattern. In a clustered deployment, point it at a distributed CacheBox provider (Redis, Couchbase, etc.) so every node shares the same buckets.

Groups: Inherit Middleware

Attach middleware to a group() and every route inside inherits it, ahead of its own entries. Nested groups compose outer-first:

group( { pattern : "/api", middleware : [ "RequireApiKey" ] }, () => {

    route( "/products" ).toHandler( "products" );
    // RequireApiKey

    route( "/orders" ).middleware( "RateLimiter" ).toHandler( "orders" );
    // RequireApiKey → RateLimiter

    group( { pattern : "/admin", middleware : [ "RequireAdmin" ] }, () => {
        route( "/settings" ).toHandler( "settings" );
        // RequireApiKey → RequireAdmin
    } );

} );

Named Bundles With middlewareGroup()

Once you have a few middleware classes, the same combinations show up everywhere. Register them once:

// config/Router.bx
function configure(){

    // Declare bundles FIRST
    middlewareGroup( "web",   [ "RequireLogin" ] );
    middlewareGroup( "admin", [ "RequireLogin", "RequireRole", "AuditTrail" ] );
    middlewareGroup( "api",   [ "RequireApiKey", "RateLimiter" ] );

    group( { pattern : "/api/v1", middleware : [ "api" ] }, () => {
        resources( "orders" );
        resources( "products" );
    } );

    group( { pattern : "/admin", middleware : [ "admin" ] }, () => {
        route( "/dashboard" ).toHandler( "admin.dashboard" );
        route( "/users/:id?" ).meta( { role : "admin" } ).toHandler( "admin.users" );
    } );

}

Groups expand in place wherever they're referenced, and a bundle can contain another bundle's name. middlewareGroup() also accepts a point, so an entire bundle can run at postProcess.

Opting Out With withoutMiddleware()

Inheritance is great until one route needs to be the exception. The classic case: a health check inside an authenticated API group.

group( { pattern : "/api", middleware : [ "api" ] }, () => {

    route( "/users" ).toHandler( "users" );
    // RequireApiKey, RateLimiter

    route( "/health" ).withoutMiddleware( "api" ).toHandler( "health" );
    // nothing from "api"

    route( "/webhooks/stripe" ).withoutMiddleware( "RequireApiKey" ).toHandler( "webhooks.stripe" );
    // RateLimiter only, Stripe signs its own requests

    route( "/status" ).withoutMiddleware( "*" ).toHandler( "status" );
    // nothing at all

} );

Exclusions match by WireBox ID or by group name. Excluding a group removes every member it expanded to. withoutMiddleware() can sit anywhere in the fluent chain, since exclusions are applied after inheritance is resolved.

One limitation: a closure has no name, so an inherited closure can't be excluded individually. If you expect a route to opt out of something, make it a named WireBox target.

Testing Middleware

Because middleware is ordinary WireBox objects, you can unit test it in isolation and integration test it through the router.

Unit test the class:

// tests/specs/unit/RequireRoleTest.bx
@model( "models.middleware.RequireRole")
class extends="coldbox.system.testing.BaseModelTest" {

    function run(){
        describe( "RequireRole middleware", () => {

            beforeEach( () => {
                setup();
                mockAuth = createStub().$( "getUser", createStub().$( "hasRole", false ) );
                model.$property( propertyName : "auth", mock : mockAuth );
            } );

            it( "blocks users without the route's role", () => {
                var event = createMock( "coldbox.system.web.context.RequestContext" );
                event.$( "getCurrentRouteMeta", { role : "admin" } );
                event.$( "renderData", event );
                event.$( "noExecution", event );

                expect( model.preProcess( event, {}, {} ) ).toBeTrue();
                expect( event.$once( "renderData" ) ).toBeTrue();
            } );

        } );
    }

}

Integration test the route:

// tests/specs/integration/AdminSecuritySpec.bx
@appMapping( "/" )
class extends="coldbox.system.testing.BaseTestCase" {

    function run(){
        describe( "Admin routes", () => {

            beforeEach( () => setup() );

            it( "redirects anonymous users to login", () => {
                var event = this.get( "/admin/dashboard" );
                expect( event ).toRedirectTo( "login" );
            } );

            it( "lets the health check through without an API key", () => {
                var event = this.get( "/api/health" );
                expect( event.getStatusCode() ).toBe( 200 );
            } );

        } );
    }

}

Gotchas Worth Knowing

1. Register groups before you reference them. Expansion happens at registration time. A name referenced before its middlewareGroup() call is treated as a literal WireBox ID, and you'll get an injector error on the first request. Put all middlewareGroup() calls at the top of configure().

2. return true stops the middleware, not the request. It short-circuits the remaining middleware at that point. The handler still runs. To actually stop the request, do what you'd do in an interceptor: event.relocate(), or event.renderData(...).noExecution().

3. It's before/after, not wrapping. A single target can't run code both before and after the handler in one call. If you need true "around" behavior, like timing a request or catching everything downstream, use aroundHandler in your handler or a global interceptor.

4. Terminators that expand into many routes inherit group middleware. toAi() and toAiGateway() generate several sub-routes each. Wrap them in a group( { middleware : [...] } ) to protect all of them. We'll use this pattern in Parts 4 and 5.

When to Use What

You need...Use
Something on every request (logging, locale, security headers)Global interceptor
Something on specific routes or route familiesRoute middleware
A reusable combination of route middlewaremiddlewareGroup()
Wrapping the handler (before and after in one call)aroundHandler
Per-route configuration of a shared middleware.meta() + getCurrentRouteMeta()

Why This Matters

For individual developers, route middleware means less boilerplate and fewer "why is this route behaving differently?" debugging sessions.

For teams and technical leads, it means something more valuable: your security and cross-cutting policy becomes reviewable in one place. A pull request that changes who can access /admin/billing shows up as a one-line diff in the router, not as a subtle change to a regex in an interceptor. That's an auditability win you'll appreciate the first time compliance asks.

Up Next

In Part 2: HTTP Caching Done Right, we'll put ETags, Last-Modified, and Cache-Control to work, and see how withCache() lets the router declare caching policy right next to the middleware you just learned.

Add Your Comment

Recent Entries

ACF2021 extended support ends on November 10. Is Your Application Ready?

ACF2021 extended support ends on November 10. Is Your Application Ready?

Adobe ColdFusion 2021 reaches the end of extended support on November 10, 2026. If your application still runs on this release, the time to decide what comes next is now.

Adobe ended core support for ColdFusion 2021 on November 10, 2025. The current extended support period is best effort and does not include security patches or hotfixes. When it ends on November 10 2026, organizations will no longer have that support option from Adobe for ColdFusion 2021.

What ...

Cristobal Escobar
Cristobal Escobar
October 13, 2026
TestBox 7.2 Real Browser Testing, Smarter Reports, and Agent-Ready Results

TestBox 7.2 Real Browser Testing, Smarter Reports, and Agent-Ready Results

Ortus Solutions today announces the general availability of TestBox 7.2, the biggest TestBox release in years. Built on the new bx-playwright 1.0 module, BoxLang specs can now drive a real browser, assert with web-first matchers that wait until they pass, and keep the screenshots, traces and videos of every failure. Around it, TestBox gains spec retries, a Run Failed option, a rebuilt set of HTML reporters with Ask AI on every failure, and a new Agent reporter that lets AI agents read a test run at minimal token cost.

Luis Majano
Luis Majano
October 13, 2026