Skip to content

Digging Deeper

Authentication Patterns

This page shows you how to wire authentication into a Wheels app using the built-in strategies. You’ll register an Authenticator in the DI container, add SessionStrategy for browser logins, layer in JwtStrategy or TokenStrategy for API clients, and combine them so one app serves both HTML and JSON without branching logic in every controller.

You’ll learn:

  • How to register wheels.auth.SessionStrategy for classic cookie-based login
  • How to hash passwords today, given the current bcrypt gap
  • How to add wheels.auth.JwtStrategy and wheels.auth.TokenStrategy for API auth
  • How to combine strategies so session auth protects HTML routes and token auth protects /api/*

Wheels ships four auth components under wheels.auth.*:

ComponentRole
AuthenticatorRegistry that tries registered strategies in order and returns the first success.
SessionStrategyReads a principal struct from the session scope. Handles login/logout.
TokenStrategyValidates an Authorization: Bearer <token> header (or an opt-in query parameter) against a validator callback or a static token map.
JwtStrategyValidates a signed JWT (Authorization: Bearer <jwt>) via JwtService.

Each strategy implements a small interface (getName(), supports(request), authenticate(request)). The authenticator picks the first strategy whose supports() returns true, runs its authenticate(), and returns a result struct:

illustrative — do not type
{
success: true,
principal: { id: 42, email: "alice@example.com", role: "admin" },
strategy: "session",
error: "",
statusCode: 200
}

Controllers care about result.success and result.principal. They do not care which strategy produced the result — that’s the whole point of the registry.

The built-in session flow is what Tutorial Part 6b walks you through. Register once in config/services.cfm, wire the strategy into the authenticator, then call service("authenticator").authenticate(request) from a filter.

config/services.cfm
local.di = injector();
local.di.map("authenticator").to("wheels.auth.Authenticator").asSingleton();
local.di.map("sessionStrategy").to("wheels.auth.SessionStrategy").asSingleton();

Both are singletons — one instance per app lifetime. The authenticator holds its strategy registry in instance state, so a singleton is the correct scope.

Wire the session strategy into the authenticator on app init. The hasStrategy check just avoids needlessly re-registering on a warm reload — duplicates can’t stack either way, because registerStrategy() replaces any existing entry with the same name:

config/app.cfm or equivalent init hook
if (StructKeyExists(application, "wheelsdi") && application.wheelsdi.containsInstance("authenticator")) {
var auth = service("authenticator");
var sessionStrategy = service("sessionStrategy");
if (!auth.hasStrategy("session")) {
auth.registerStrategy(name="session", strategy=sessionStrategy);
}
}

After verifying the password in your Sessions controller, call sessionStrategy.login(principal) to stash the identity. logout() tears it down:

app/controllers/Sessions.cfc
component extends="Controller" {
function new() {
}
function create() {
user = model("User").findOne(where="email = :email", values={email: LCase(Trim(params.email ?: ""))});
if (IsObject(user) && user.authenticate(params.password ?: "")) {
service("sessionStrategy").login(principal={id: user.id, email: user.email});
redirectTo(route="posts", success="Welcome back.");
} else {
flashInsert(error="Invalid email or password");
redirectTo(route="login");
}
}
function delete() {
service("sessionStrategy").logout();
redirectTo(route="login", success="Logged out.");
}
}

The principal struct is whatever shape you pick — {id, email} is a reasonable minimum; richer apps store {id, email, roles, displayName}. The strategy round-trips the struct verbatim.

Protect controller actions with a filter that asks the authenticator:

app/controllers/Posts.cfc
component extends="Controller" {
function config() {
filters(through="authenticate", except="index,show");
}
function index() {
posts = model("Post").findAll();
}
private function authenticate() {
var result = service("authenticator").authenticate(request);
if (!result.success) {
flashInsert(error="Please log in first");
redirectTo(route="login");
}
}
private function $currentUserId() {
var result = service("authenticator").authenticate(request);
return result.success ? (result.principal.id ?: 0) : 0;
}
}

The filter must be private. A public filter becomes a routable action — Wheels will happily serve /posts/authenticate and run the filter body as an action. $currentUserId() uses the $ prefix to mark it as internal by convention.

Model-level hashing stays the same regardless of which strategy you use — the strategy doesn’t touch passwords, it just round-trips a principal struct after you’ve already verified credentials.

The stopgap pattern is a beforeValidation callback that hashes plaintext and clears it, plus an authenticate(password) instance method. The hash callback registers beforeValidation rather than beforeSave so the NOT NULL passwordHash and passwordSalt columns are populated by the time Wheels’ automatic validatesPresenceOf rules check them — registering on beforeSave causes every signup to fail with Passwordhash can't be empty:

app/models/User.cfc
component extends="Model" {
function config() {
validatesPresenceOf("email");
validatesUniquenessOf(property="email");
beforeValidation("normalizeEmail");
beforeValidation("hashPassword");
}
private function normalizeEmail() {
if (StructKeyExists(this, "email")) {
this.email = Trim(LCase(this.email));
}
}
private function hashPassword() {
if (StructKeyExists(this, "password") && Len(this.password)) {
if (!StructKeyExists(this, "passwordSalt") || !Len(this.passwordSalt ?: "")) {
this.passwordSalt = generateSecretKey("AES");
}
this.passwordHash = Hash(this.password & this.passwordSalt, "SHA-256");
StructDelete(this, "password");
}
}
public boolean function authenticate(required string password) {
if (!Len(this.passwordHash ?: "") || !Len(this.passwordSalt ?: "")) {
return false;
}
var candidate = Hash(arguments.password & this.passwordSalt, "SHA-256");
return (candidate == this.passwordHash);
}
}

normalizeEmail runs before validation so uniqueness sees a canonical address. hashPassword only fires when this.password is set (the caller just assigned a new one), generates a per-user salt on first write, and scrubs the plaintext before the save hits the database. When bcrypt ships, swap Hash(... "SHA-256") for the bcrypt equivalent — nothing else changes.

TokenStrategy extracts a bearer token from Authorization: Bearer <token> (or, when explicitly enabled via queryParam, a query parameter) and validates it against either a validator callback — typical for database-backed API keys — or a static token map — useful for tests and seeded dev keys. Static tokens are matched case-sensitively and compared in constant time.

config/app.cfm or equivalent init hook
if (StructKeyExists(application, "wheelsdi") && application.wheelsdi.containsInstance("authenticator")) {
var auth = service("authenticator");
if (!auth.hasStrategy("token")) {
var tokenStrategy = new wheels.auth.TokenStrategy(validator=function(token) {
var apiKey = model("ApiKey").findOne(where="token = :token AND revokedAt IS NULL", values={token: arguments.token});
if (IsObject(apiKey)) {
return {id: apiKey.userId, role: apiKey.role, source: "api-key"};
}
return false;
});
auth.registerStrategy(name="token", strategy=tokenStrategy);
}
}

The callback receives the extracted token string and returns a principal struct on success or false on failure. Any lookup works — a database query, a Redis check, a remote HTTP call. Keep it fast: the callback runs on every authenticated request.

illustrative — static tokens
var tokenStrategy = new wheels.auth.TokenStrategy(tokens={
"dev-key-alice": {id: 1, role: "admin"},
"dev-key-bob": {id: 2, role: "reader"}
});

Static maps are fine for local dev and test fixtures. Don’t commit production keys to source.

With the strategy registered, controllers use the same authenticator call as session auth:

app/controllers/Api.cfc
component extends="Controller" {
function config() {
filters(through="requireToken");
provides("json");
}
function index() {
var result = service("authenticator").authenticate(request);
renderWith({items: model("Item").findAll(where="userId=:uid", values={uid: result.principal.id})});
}
private function requireToken() {
var result = service("authenticator").authenticate(request);
if (!result.success) {
renderWith(data={error: result.error}, status=result.statusCode);
}
}
}

JWTs are self-contained signed tokens — the client holds them, you verify the signature, and you never hit the database to look them up. JwtStrategy wraps JwtService, which does HMAC-SHA256 encode/decode.

config/app.cfm or equivalent init hook
if (StructKeyExists(application, "wheelsdi") && application.wheelsdi.containsInstance("authenticator")) {
var auth = service("authenticator");
if (!auth.hasStrategy("jwt")) {
var jwtService = new wheels.auth.JwtService(
secretKey=env("JWT_SECRET"),
defaultExpiry=3600,
issuer="myapp"
);
var jwtStrategy = new wheels.auth.JwtStrategy(jwtService=jwtService);
auth.registerStrategy(name="jwt", strategy=jwtStrategy);
}
}

The secret lives in the environment (env("JWT_SECRET")) — never commit it, and make it at least 32 random bytes (JwtService throws at construction for empty or shorter secrets, per RFC 7518 §3.2). defaultExpiry is seconds; 3600 = one hour. issuer is stamped as the iss claim, and decode() rejects tokens whose iss is missing or doesn’t match it.

app/controllers/ApiSessions.cfc
component extends="Controller" {
function config() {
provides("json");
}
function create() {
var user = model("User").findOne(where="email = :email", values={email: params.email ?: ""});
if (IsObject(user) && user.authenticate(params.password ?: "")) {
var jwtService = new wheels.auth.JwtService(secretKey=env("JWT_SECRET"));
var token = jwtService.encode({sub: user.id, email: user.email, role: user.role});
renderWith({token: token, expiresIn: 3600});
} else {
renderWith(data={error: "Invalid credentials"}, status=401);
}
}
}

Clients send the token back on subsequent requests as Authorization: Bearer <token>. The JWT strategy extracts it, JwtService.decode() verifies the signature and expiry, and the decoded claims become the principal:

app/controllers/Api.cfc — JWT-protected action
component extends="Controller" {
function config() {
filters(through="requireJwt");
provides("json");
}
function profile() {
var result = service("authenticator").authenticate(request);
renderWith({userId: result.principal.sub, email: result.principal.email});
}
private function requireJwt() {
var result = service("authenticator").authenticate(request);
if (!result.success) {
renderWith(data={error: result.error}, status=result.statusCode);
}
}
}

On expired tokens JwtStrategy returns error="JWT token has expired" and statusCode=401; on bad signatures, error="JWT signature verification failed". The controller doesn’t need to distinguish — both fail the result.success check and return 401.

The registry pattern pays off when one app serves both HTML and JSON. Register session auth for browser routes, JWT for /api/*, and the authenticator picks the right one per request based on what the client sends.

config/app.cfm — register both strategies
if (StructKeyExists(application, "wheelsdi") && application.wheelsdi.containsInstance("authenticator")) {
var auth = service("authenticator");
if (!auth.hasStrategy("jwt")) {
var jwtService = new wheels.auth.JwtService(secretKey=env("JWT_SECRET"));
auth.registerStrategy(name="jwt", strategy=new wheels.auth.JwtStrategy(jwtService=jwtService));
}
if (!auth.hasStrategy("session")) {
auth.registerStrategy(name="session", strategy=service("sessionStrategy"));
}
}

Order matters. JwtStrategy.supports(request) returns true only when an Authorization: Bearer header is present; SessionStrategy.supports(request) returns true only when the session already has a principal. An API call carrying a bearer token is handled by JWT; a browser request carrying a session cookie is handled by session; neither matches, authentication fails with 401.

A single authenticate filter in a base controller now protects both kinds of routes without branching:

app/controllers/Controller.cfc — app-wide base
component extends="wheels.Controller" {
private function authenticate() {
var result = service("authenticator").authenticate(request);
if (!result.success) {
if ((request.format ?: "html") == "json") {
renderWith(data={error: result.error}, status=result.statusCode);
} else {
flashInsert(error="Please log in first");
redirectTo(route="login");
}
}
}
}

You don’t have to. Authenticator, SessionStrategy, TokenStrategy, and JwtStrategy are plain CFCs — nothing about them requires injector(). Store the authenticator in application scope at init, instantiate the strategies with new, and call them directly. The DI container buys you testability (register a mock under the same name in config/testing/services.cfm) and a clean seam for environment-specific swaps. If your app is small and doesn’t need either, skip it.

  • Session fixation. SessionStrategy.login() calls sessionRotate() automatically on engines that support it (Lucee). If you’re on an engine without session rotation, issue a new session ID manually before storing the principal.
  • Timing-safe comparison. The stopgap authenticate() uses == to compare hashes, which is not timing-safe. Under load an attacker can measure response times to recover the hash byte-by-byte. Swap for a constant-time comparator (Compare() in CFML is not guaranteed constant-time either — use a dedicated Java helper) when you ship bcrypt.
  • Secret rotation. JwtService takes a single secretKey. Rotating it invalidates every outstanding token. For gradual rotation, validate against a list of current + previous secrets during the cutover window; the shipped JwtService doesn’t do this natively, so you’ll need to wrap it.
  • JWT revocation. JWTs are stateless by design — you cannot revoke an issued token before it expires unless you maintain a server-side deny-list and check it on every request. Keep token expiry short (minutes, not days) and use a refresh-token flow if you need long sessions.
  • Token leakage in URLs. Query-string tokens are disabled by default on both TokenStrategy and JwtStrategy (queryParam=""). Query strings show up in access logs, referer headers, and browser history — prefer the Authorization header, and only set queryParam if you must support URL-based keys.
  • Weak JWT secrets. JwtService refuses to construct with an empty secret (Wheels.Auth.JWT.InvalidSecretKey) or one shorter than 32 bytes (Wheels.Auth.JWT.WeakSecretKey), per RFC 7518 §3.2 — HMAC-SHA256 needs at least 256 bits of key material. Generate a random 32+ byte secret and load it from the environment.