Handlers & Routing

Every handler, its actions, and how Router.bx wires URLs to them.

On this page

Handlers & Routing

Handler map

HandlerBasePurpose
AuditLog.bxBaseSecureHandlerAudit trail browsing, export, and purging
Assets.bxEventHandlerStreams user avatars and the branding logo
Auth.bxEventHandlerLogin, registration, invitations, password reset - all public
BaseSecureHandler.bxRestHandlerBase class for every admin handler
Dashboard.bxBaseSecureHandlerThe authenticated landing page
Main.bxEventHandlerImplicit-event handler - see Architecture
Permissions.bxBaseSecureHandlerPermission slug CRUD
Profile.bxBaseSecureHandlerSelf-service profile, password, API tokens, passkeys
Roles.bxBaseSecureHandlerRole CRUD + user assignment
Settings.bxBaseSecureHandlerApp settings registry
Users.bxBaseSecureHandlerUser administration

BaseSecureHandler

Every protected handler extends BaseSecureHandler, whose preHandler verifies CSRF on every state-changing request, forces the Admin layout, and redirects to profile/passkey-required when cbRequirePasskey is on and the user has none. It also provides shared helpers (getApiResults(), ensureSortDirection(), getPagination()):

component extends="coldbox.system.RestHandler" {

    function preHandler( event, rc, prc ){
        // ...CSRF verification, deny-by-default...
        event.setLayout( "Admin" );
        // ...passkey enforcement...
    }

}

Building a new secured handler starts the same way every time:

component extends="BaseSecureHandler" secured {

    function index( event, rc, prc ){
        prc.pageTitle = "My Page";
        event.setView( "myhandler/index" );
    }

}

AuditLog

@secured("auditlog:admin,auditlog:read") at the class level; every action but index is @remote:

  • index, search, show - browse and filter the audit trail
  • export - @secured("auditlog:admin,auditlog:export"), streams CSV
  • purge - @secured("auditlog:admin,auditlog:delete"), deletes entries older than a cutoff
  • clear - @secured("auditlog:admin"), deletes every entry

Assets

No @secured annotation at the class level - it streams binary files from the private cbfs assets disk (see Database & ORM and app/config/modules/cbfs.bx), which sits outside the webroot and is otherwise unreachable:

  • avatar - @secured (any authenticated user), streams a user's sm/lg avatar JPEG variant
  • logo - public, streams the sm/lg branding logo PNG variant so the login screen and other guest pages can render it

Both actions 404 (rather than erroring) for an unrecognized userId/size shape or when the requested file simply does not exist, so a caller cannot distinguish "no avatar" from "no such user" by response shape alone. Resizing, cropping, and storage go through ImageService (app/models/system/ImageService.bx), invoked via getInstance() inside each action rather than an @inject property - see the docblock on Assets.bx for why (a WireBox boot-order quirk with handler-triggered singleton construction).

Auth

No @secured annotation - these actions must stay reachable by guests:

  • login / doLogin (GET/POST) - CSRF-verified, calls securityService.login(), supports rememberMe
  • register / doRegister - gated by the cbAllowRegistration setting
  • checkEmailAvailability - JSON endpoint for live email-availability checks
  • verifyRegistration - consumes a PURPOSE_REGISTRATION action token
  • activateInvitation / doActivateInvitation - sets a password for an invited, admin-created user
  • forgotPassword / doForgotPassword - gated by cbAllowForgotPassword
  • resetPassword / doResetPassword - validates the reset token, sets a new password
  • verifyEmailChange - consumes a PURPOSE_EMAIL_CHANGE action token
  • logout - calls securityService.logout()

preHandler redirects an already-authenticated visitor straight to the dashboard, and sets the layout from prc.settings.cbLoginLayout (AuthSplit by default - see guides/security.md); verifyEmailChange and logout are exempted from that redirect so they stay reachable whether or not the visitor is already authenticated.

Dashboard

@secured (any authenticated user, no specific permission required):

  • index - the dashboard home
  • notAuthorized - the target of invalidAuthorizationEvent, shown when an authenticated user is missing a required permission

Permissions

@secured("permissions:admin,permissions:read") at the class level:

  • index
  • create - @secured("permissions:admin,permissions:write")
  • update / delete - @remote, same write/delete permissions

Profile

@secured self-service actions for the current user, all @remote AJAX endpoints except index:

  • index, passkeyRequired
  • save, doPasswordChange
  • requestEmailChange / cancelEmailChange - starts/cancels a pending email change, confirmed via Auth.verifyEmailChange
  • listTokens / createToken / updateToken / deleteToken - API tokens
  • listPasskeys / updatePasskey / deletePasskey
  • uploadAvatar / deleteAvatar - accepts the image as a base64 data URI in rc.avatar (BoxLang has no multipart/form-data parser, so uploads travel as JSON), decoded via BaseSecureHandler.decodeDataUri(); streamed back by Assets.avatar

Every one of these is CSRF-verified by BaseSecureHandler unless it is reached over a safe HTTP method - see CSRF verification.

Roles

@secured("roles:admin,roles:read") at the class level; every action but index is @remote:

  • index
  • create / update / delete - @secured("roles:admin,roles:write" / "...:delete")
  • users / availableUsers - list users on/available for a role
  • addUser / removeUser - @secured("roles:admin")

Settings

@secured("settings:admin,settings:read") at the class level:

  • index
  • registry / registrySearch - paginated settings registry
  • createRegistry / updateRegistry / toggleRegistryStatus / deleteRegistry - settings:admin,settings:write
  • save - bulk save of core settings
  • uploadLogo / deleteLogo - settings:admin,settings:write, same base64 data URI convention as Profile.uploadAvatar; stores/restores the cbAppLogo setting and streams back via Assets.logo
  • Admin utilities (all settings:admin): clearTemplateCache, clearSessionsCache, revokeRememberTokens, flushSettingsCache

Users

@secured("users:admin,users:read") at the class level:

  • index, search
  • create / update / delete / resendInvitation - users:admin,users:write / ...:delete
  • show - users:read
  • Admin-only (users:admin): updateProfile, setStatus, resetPassword, verify, revokeRememberTokens, addRole/removeRole, addPermission/removePermission, savePreferences, revokeToken/revokeAllTokens

ensureNotSelf() guards several of these to block an admin from demoting or removing their own roles.

The Users admin page

CSRF verification

app/config/modules/cbsecurity.bx sets csrf.enableAutoVerifier: false, so there is no global interceptor. Instead, BaseSecureHandler.preHandler() verifies CSRF deny-by-default for every handler that extends it:

static {
    // The safe methods of RFC 9110, exempt from CSRF verification below.
    SAFE_HTTP_METHODS = "GET,HEAD,OPTIONS"
}

function preHandler( event, rc, prc ) {
    if (
        !static.SAFE_HTTP_METHODS.listFindNoCase( event.getHTTPMethod() )
        && !csrfVerify( rc.csrf ?: "" )
    ) {
        return onInvalidCSRF( argumentCollection = arguments )
    }
    // ...
}

What this means when you extend a secured handler:

  • You do not opt in. Any action reached over POST, PUT, PATCH, or DELETE must carry a valid rc.csrf, from the day you add it. There is no per-handler list to remember to update.
  • Safe methods are exempt. GET, HEAD, and OPTIONS must not change state, so they carry no CSRF risk, and OPTIONS (CORS preflight) cannot carry a token at all. If a safe method in your code does change state, that is the bug to fix.
  • onInvalidCSRF() is overridable. The base implementation aborts with an authorization failure, which is what the JSON/AJAX endpoints want. Permissions and Settings override it to flash a message and redirect, so a browser form gets a page instead of a bare 403. Override it in your own handler when it renders HTML.
`Auth` is not a secured handler

Auth extends coldbox.system.EventHandler, not BaseSecureHandler, because its actions run for unauthenticated visitors and so cannot inherit the check above. Each state-changing action verifies its own token: doLogin, doRegister, doActivateInvitation, doForgotPassword, doResetPassword, and logout.

Route map (app/config/Router.bx)

All routes are declared in one configure() function:

route( "/healthcheck" ).to( () => "Ok!" );

get( "dashboard" ).to( "Dashboard.index" );

resources( "permissions", parameterName = "permissionId" );

route( "roles/:roleId/available-users" ).to( "Roles.availableUsers" );
route( "roles/:roleId/users" ).toAction( { POST: "addUser" } );
route( "roles/:roleId/users/:userId" ).toAction( { DELETE: "removeUser" } );
resources( "roles", parameterName = "roleId" );

resources( "users", parameterName = "userId" );

route( "profile" ).toAction( { GET: "index", POST: "save" } );

// @app_routes@  ← insertion point for module/scaffold-generated routes

route( ":handler/:action?" ).end(); // conventions-based catch-all

See Reference: Route Map for the full table of every method, URL, target action, and required permission.

Edit this page Download Markdown Last updated Sep 16, 2026, 5:56:49 PM