Handlers & Routing
Every handler, its actions, and how Router.bx wires URLs to them.
On this page
Handlers & Routing
Handler map
| Handler | Base | Purpose |
|---|---|---|
AuditLog.bx | BaseSecureHandler | Audit trail browsing, export, and purging |
Assets.bx | EventHandler | Streams user avatars and the branding logo |
Auth.bx | EventHandler | Login, registration, invitations, password reset - all public |
BaseSecureHandler.bx | RestHandler | Base class for every admin handler |
Dashboard.bx | BaseSecureHandler | The authenticated landing page |
Main.bx | EventHandler | Implicit-event handler - see Architecture |
Permissions.bx | BaseSecureHandler | Permission slug CRUD |
Profile.bx | BaseSecureHandler | Self-service profile, password, API tokens, passkeys |
Roles.bx | BaseSecureHandler | Role CRUD + user assignment |
Settings.bx | BaseSecureHandler | App settings registry |
Users.bx | BaseSecureHandler | User 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 trailexport-@secured("auditlog:admin,auditlog:export"), streams CSVpurge-@secured("auditlog:admin,auditlog:delete"), deletes entries older than a cutoffclear-@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'ssm/lgavatar JPEG variantlogo- public, streams thesm/lgbranding 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, callssecurityService.login(), supportsrememberMeregister/doRegister- gated by thecbAllowRegistrationsettingcheckEmailAvailability- JSON endpoint for live email-availability checksverifyRegistration- consumes aPURPOSE_REGISTRATIONaction tokenactivateInvitation/doActivateInvitation- sets a password for an invited, admin-created userforgotPassword/doForgotPassword- gated bycbAllowForgotPasswordresetPassword/doResetPassword- validates the reset token, sets a new passwordverifyEmailChange- consumes aPURPOSE_EMAIL_CHANGEaction tokenlogout- callssecurityService.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 homenotAuthorized- the target ofinvalidAuthorizationEvent, shown when an authenticated user is missing a required permission
Permissions
@secured("permissions:admin,permissions:read") at the class level:
indexcreate-@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,passkeyRequiredsave,doPasswordChangerequestEmailChange/cancelEmailChange- starts/cancels a pending email change, confirmed viaAuth.verifyEmailChangelistTokens/createToken/updateToken/deleteToken- API tokenslistPasskeys/updatePasskey/deletePasskeyuploadAvatar/deleteAvatar- accepts the image as a base64 data URI inrc.avatar(BoxLang has no multipart/form-data parser, so uploads travel as JSON), decoded viaBaseSecureHandler.decodeDataUri(); streamed back byAssets.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:
indexcreate/update/delete-@secured("roles:admin,roles:write"/"...:delete")users/availableUsers- list users on/available for a roleaddUser/removeUser-@secured("roles:admin")
Settings
@secured("settings:admin,settings:read") at the class level:
indexregistry/registrySearch- paginated settings registrycreateRegistry/updateRegistry/toggleRegistryStatus/deleteRegistry-settings:admin,settings:writesave- bulk save of core settingsuploadLogo/deleteLogo-settings:admin,settings:write, same base64 data URI convention asProfile.uploadAvatar; stores/restores thecbAppLogosetting and streams back viaAssets.logo- Admin utilities (all
settings:admin):clearTemplateCache,clearSessionsCache,revokeRememberTokens,flushSettingsCache
Users
@secured("users:admin,users:read") at the class level:
index,searchcreate/update/delete/resendInvitation-users:admin,users:write/...:deleteshow-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.

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, orDELETEmust carry a validrc.csrf, from the day you add it. There is no per-handler list to remember to update. - Safe methods are exempt.
GET,HEAD, andOPTIONSmust not change state, so they carry no CSRF risk, andOPTIONS(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.PermissionsandSettingsoverride 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 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.