Start Here
Part 6: Authentication
You’ll add signup, login, and logout to the blog — first by hand, so you can see every moving part, then by swapping in the built-in wheels.auth.SessionStrategy to see how much of that code the framework already ships.
You’ll learn:
- Password hashing with a per-user salt
- Session-based login via the
sessionscope - A private
authenticatefilter that protects sensitive actions - Wheels’ built-in
wheels.auth.SessionStrategy— and when to prefer it
Estimated time: 45 minutes.
Where we left off
Section titled “Where we left off”Part 5 left you with posts and comments. The schema has posts(id, title, body, status, publishedAt, createdAt, updatedAt) and comments(id, postId, author, body, createdAt, updatedAt). Turbo Frames inline the post form errors; Turbo Streams append new comments without a reload.
Directoryblog
Directoryapp
Directorycontrollers
- Controller.cfc
- Main.cfc
- Posts.cfc
- Comments.cfc
Directorydb
- seeds.cfm
Directorymigrator
Directorymigrations
- 20260419120000_create_posts_table.cfc
- 20260419130000_create_comments_table.cfc
Directorymodels
- Post.cfc
- Comment.cfc
Directoryviews
- layout.cfm
- main
- posts
- comments
Directoryconfig
- routes.cfm
- settings.cfm
Directorydb
- development.sqlite
Why two versions
Section titled “Why two versions”The first version (6a) hand-rolls session auth. You’ll add a User model with its own password-hashing callback, write a Sessions controller that reads and writes session.userId directly, and protect the Posts controller with a filter you can read top-to-bottom in ten lines. Nothing magical. When something breaks, you know where to look.
The second version (6b) replaces the session-scope mechanics with wheels.auth.SessionStrategy — a component that encapsulates “store a principal struct at a configured session key, expose success/failure results, handle login/logout.” Using it drops about 60% of the controller code and gives you a pluggable seam: swap the session strategy for a JWT strategy later without touching controllers. Building both gives you intuition for when a bespoke flow is warranted (a small app that only does session auth) and when the built-in pays for itself (anything that will grow API tokens, JWTs, or SSO).
Part 6a: Roll Your Own
Section titled “Part 6a: Roll Your Own”Add a User model
Section titled “Add a User model”-
Create
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);}}
Four pieces to notice:
beforeValidation("normalizeEmail")runs before the validation pass, so the uniqueness check sees a trimmed, lowercased email. Without the normalize step, “Alice@example.com” and “alice@example.com” would look like different users to the database.beforeValidation("hashPassword")runs before each validation pass. It only does anything whenthis.passwordis set — that is, when the caller just assigned a new password. It generates a per-user salt if one isn’t already stored, hashespassword + saltwith SHA-256, stashes the result inthis.passwordHash, and scrubsthis.passwordso the plaintext never reaches the database. The callback runs before validation (rather thanbeforeSave) because Wheels’ automaticvalidatesPresenceOfrules treat the NOT NULLpasswordHashandpasswordSaltcolumns as required — they need to be populated by the time validation fires, otherwise every signup fails withPasswordhash can't be empty.Hash(input, "SHA-256")returns uppercase hex — 64 characters. That’s why the migration below reserves 64 chars forpasswordHash.authenticate(password)re-hashes the candidate password with the stored salt and compares. Equal means the user typed the right password.
Migrate the users table
Section titled “Migrate the users table”-
Create
app/migrator/migrations/20260419140000_create_users_table.cfc:component extends="wheels.migrator.Migration" hint="Create users table" {function up() {transaction {try {t = createTable(name="users");t.string(columnNames="email", allowNull=false, limit=255);t.string(columnNames="passwordHash", allowNull=false, limit=64);t.string(columnNames="passwordSalt", allowNull=false, limit=48);t.timestamps();t.create();} catch (any e) {local.exception = e;}if (StructKeyExists(local, "exception")) {transaction action="rollback";Throw(errorCode="1", detail=local.exception.detail, message=local.exception.message, type="any");} else {transaction action="commit";}}}function down() {transaction {dropTable("users");transaction action="commit";}}}
passwordHash is 64 chars because that’s the width of a SHA-256 hex digest. passwordSalt is ~44 chars because generateSecretKey("AES") returns a base64-encoded AES key — 48 gives a little headroom. timestamps() creates three columns: createdAt, updatedAt, and deletedAt (the soft-delete marker).
Signup — the Users controller
Section titled “Signup — the Users controller”-
Create
app/controllers/Users.cfc:component extends="Controller" {function new() {user = model("User").new();}function create() {user = model("User").new(params.user);if (user.save()) {session.userId = user.id;redirectTo(route="posts", success="Welcome! You're signed up and logged in.");} else {renderView(action="new");}}}
new renders an empty signup form. create handles the POST: params.user is the struct gathered from the form’s bracketed inputs (user[email], user[password]). If validation passes and the save succeeds, we set session.userId — the user is now logged in — and redirect with a flash success message. If the save fails (missing email, duplicate email), we re-render the new view, which will display the validation errors.
Login and logout — the Sessions controller
Section titled “Login and logout — the Sessions controller”-
Create
app/controllers/Sessions.cfc:component extends="Controller" {function new() {}function create() {emailVal = LCase(Trim(params.email ?: ""));user = model("User").findOneByEmail(emailVal);if (IsObject(user) && user.authenticate(params.password ?: "")) {session.userId = user.id;redirectTo(route="posts", success="Welcome back.");} else {flashInsert(error="Invalid email or password");redirectTo(route="login");}}function delete() {StructDelete(session, "userId");redirectTo(route="login", success="Logged out.");}}
Three actions, three responsibilities:
newrenders the login form. No setup needed; the view uses no dynamic data.createis the login POST. It normalizes the submitted email the same way the model does on save, looks up the user via the dynamic finderfindOneByEmail— Wheels generatesfindOneBy<Property>for every column, and the value is bound as a parameter so this is injection-safe. Then it callsuser.authenticate(password). Success setssession.userIdand redirects; failure flashes an error and sends the browser back to the login form.deleteis the logout action. It clearssession.userIdand redirects.
Protect the Posts controller
Section titled “Protect the Posts controller”-
Replace
app/controllers/Posts.cfc:component extends="Controller" {function config() {super.config();filters(through="authenticate", except="index,show");}function index() {posts = model("Post").published().findAll(include="comments", order="publishedAt DESC");}function show() {post = params.post;}function new() {post = model("Post").new();}function create() {post = model("Post").new(params.post);post.userId = session.userId;if (post.save()) {redirectTo(route="post", key=post.id);} else {renderPartial(partial="form", post=post, layout=false);}}function edit() {post = params.post;ownershipCheck(post);}function update() {post = params.post;ownershipCheck(post);if (post.update(params.post)) {redirectTo(route="post", key=post.id);} else {renderPartial(partial="form", post=post, layout=false);}}function delete() {post = params.post;ownershipCheck(post);post.delete();redirectTo(route="posts");}private function authenticate() {if (!StructKeyExists(session, "userId")) {flashInsert(error="Please log in first");redirectTo(route="login");}}private function ownershipCheck(required any post) {if (arguments.post.userId != (session.userId ?: 0)) {redirectTo(route="posts");}}}
config() runs once when the controller is instantiated. filters(through="authenticate", except="index,show") says: “run the authenticate method before every action, except index and show.” So anyone can browse the blog, but only logged-in users can create, edit, or delete posts.
The filter is private. This is critical: public filter methods become routable actions, which means Wheels would happily respond to /posts/authenticate and run the filter body as an action. Private filters are invisible to the router.
ownershipCheck is called explicitly at the top of edit, update, and delete. It’s not a filter because filters run on every matching action uniformly — ownership checks need the loaded post, which only the specific action has. A helper function keeps the three call sites from diverging.
Add userId to posts
Section titled “Add userId to posts”-
Create
app/migrator/migrations/20260419150000_add_user_to_posts.cfc:component extends="wheels.migrator.Migration" hint="Add userId to posts" {function up() {addColumn(table="posts", columnType="integer", columnName="userId");}function down() {removeColumn(table="posts", columnName="userId");}}
addColumn generates the right ALTER TABLE for whatever database you’re on — no need to hand-write DDL.
Wire up the Post ↔ User association
Section titled “Wire up the Post ↔ User association”The migration adds the userId column, but the models don’t yet know about each other. Add the association so post.user() and user.posts() work, and so User.delete cascades to that user’s posts.
-
Update
app/models/Post.cfc:component extends="Model" {function config() {enum(property="status", values="draft,published,archived");hasMany(name="comments", dependent="delete");belongsTo(name="user");validatesPresenceOf("title,body");validatesLengthOf(property="title", maximum=120);}} -
Update
app/models/User.cfcto declare the reverse:component extends="Model" {function config() {hasMany(name="posts", dependent="delete");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);}}
With the association in place, post.user() returns the author, user.posts() returns their posts, and deleting a user drops their posts transparently.
Signup, login, and logout routes
Section titled “Signup, login, and logout routes”-
Replace
config/routes.cfm:mapper().resources(name="posts", binding=true, callback=function(map) {map.resources(name="comments", only="create");}).get(name="signup", pattern="/signup", to="users##new").post(name="register", pattern="/signup", to="users##create").get(name="login", pattern="/login", to="sessions##new").post(name="authenticate", pattern="/login", to="sessions##create").delete(name="logout", pattern="/logout", to="sessions##delete").get(name="hello", pattern="/hello", to="main##hello").wildcard().root(to="posts##index", method="get").end();
Five new routes: two for signup (GET + POST both at /signup), two for login (GET + POST at /login), one for logout (DELETE at /logout). Each gets a named helper (signup, register, login, authenticate, logout) — we’ll reference those names from views via linkTo and startFormTag.
Signup and login views
Section titled “Signup and login views”-
Create
app/views/users/new.cfm:app/views/users/new.cfm <cfparam name="user" default=""><cfoutput><h1>Sign up</h1>#errorMessagesFor("user")##startFormTag(route="register")#<label>Email<br><input type="email" name="user[email]"></label><label>Password<br><input type="password" name="user[password]"></label><button type="submit">Sign up</button>#endFormTag()#</cfoutput> -
Create
app/views/sessions/new.cfm:app/views/sessions/new.cfm <cfoutput><h1>Log in</h1>#startFormTag(route="authenticate")#<label>Email<br><input type="email" name="email"></label><label>Password<br><input type="password" name="password"></label><button type="submit">Log in</button>#endFormTag()#<p>New here? #linkTo(route="signup", text="Sign up")#.</p></cfoutput>
The signup form uses bracket-named inputs (user[email]) so Wheels gathers them into a params.user struct. The login form uses flat names (email, password) because the controller reads them as params.email and params.password directly — there’s no model involved in login, just a lookup.
Test 6a manually
Section titled “Test 6a manually”Apply the migrations:
wheels migrate latestThen walk through the flow in the browser:
- Visit
/signup, create an account with any email and password. You’re redirected to/postsand logged in. - Click “New post” — it works. The newly-created post has
post.userIdset fromsession.userId. - Submit a logout form, then try
/posts/new— you’re redirected to/login. - Log back in. Try editing a post that belongs to a different user — the ownership check redirects you back to the index.
The /logout route is a DELETE, so a plain <a href> won’t reach it — you need a small form. Drop this anywhere in your layout (or any view) for a working “Log out” button:
#buttonTo(text="Log out", route="logout", method="delete")#buttonTo emits a one-button form with the CSRF token included and the right method set; route="logout" resolves to the named route from routes.cfm, so it stays correct if the URL pattern changes. The same shape works for any non-GET destructive action — that’s how chapter 3’s “Delete” buttons on the post show page work too.
That’s the hand-rolled version. Thirty-ish lines of logic total, no hidden machinery. Now let’s see what the framework gives you for free.
Part 6b: The Built-in Way
Section titled “Part 6b: The Built-in Way”The hand-rolled version taught you the mental model. Wheels ships wheels.auth.SessionStrategy — a component that encapsulates the session-scope mechanics behind a stable interface. Using it drops the direct session.userId reads and writes from your controllers, and it gives you a clean seam for adding a second strategy later (a JWT token in an Authorization header, say, or an API-key lookup against a database table).
The design is a registry: an Authenticator holds a list of named strategies. On each request, authenticator.authenticate(request) tries each strategy in registration order and returns the first success. Your controllers call that one method and don’t care which strategy did the work.
Register the authenticator
Section titled “Register the authenticator”-
Create
config/services.cfm:<cfscript>local.di = injector();local.di.map("authenticator").to("wheels.auth.Authenticator").asSingleton();local.di.map("sessionStrategy").to("wheels.auth.SessionStrategy").asSingleton();</cfscript>
config/services.cfm is loaded once at app init. Like every other file under config/, the body must be wrapped in <cfscript>...</cfscript> — without the wrapper, Lucee parses the file as markup and the local.di = ... lines spill onto the page as literal text instead of executing. injector() returns the DI container; .map("name").to("component.path").asSingleton() registers a binding — one instance per app lifetime, resolved on demand. Any controller or view can ask for service("authenticator") and get the same instance back every time.
Next, wire the session strategy into the authenticator so authenticate(request) has something to try. This is onApplicationStart work — it only needs to happen once per app boot. The right file is app/events/onapplicationstart.cfm. If you don’t already have one, create it; otherwise append to it:
<cfscript>if (StructKeyExists(application, "wheelsdi") && application.wheelsdi.containsInstance("authenticator")) { var auth = application.wo.service("authenticator"); var sessionStrategy = application.wo.service("sessionStrategy"); if (!auth.hasStrategy("session")) { auth.registerStrategy(name="session", strategy=sessionStrategy); }}</cfscript>Don’t put this in config/app.cfm — that file is for Application.cfc this-scope settings (this.name, this.datasources, this.sessionTimeout, etc.), not for init code. The DI container isn’t initialized when config/app.cfm runs, so application.wheelsdi doesn’t exist yet and the registration silently no-ops.
Both wheels reload and a full wheels stop && wheels start re-fire onApplicationStart — an authorized reload stops the application and the next request boots it fresh, re-running this file and config/services.cfm. Either way, this registers the strategy exactly once, and the hasStrategy check keeps repeated restarts from stacking duplicates. (CLI versions predating the #3110 fix printed a note claiming wheels reload skips onApplicationStart; that note was wrong.)
Rewrite the Sessions controller
Section titled “Rewrite the Sessions controller”-
Replace
app/controllers/Sessions.cfc:component extends="Controller" {function new() {}function create() {emailVal = LCase(Trim(params.email ?: ""));user = model("User").findOneByEmail(emailVal);if (IsObject(user) && user.authenticate(params.password ?: "")) {var sessionStrategy = application.wo.service("sessionStrategy");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() {var sessionStrategy = application.wo.service("sessionStrategy");sessionStrategy.logout();redirectTo(route="login", success="Logged out.");}}
Same public behavior. Under the hood, sessionStrategy.login({id, email}) stores a principal struct at the configured session key (default: session.wheels.auth). logout() clears it. Your controller no longer talks to the session scope directly — which means a future swap to JWT touches the strategy registration, not the controllers.
The principal is any struct you pick. {id, email} is a reasonable baseline; a larger app might store {id, email, roles, displayName}. The strategy doesn’t care about the shape — it round-trips whatever you hand it.
Rewrite the Users controller to match
Section titled “Rewrite the Users controller to match”6a’s Users.create set session.userId = user.id directly after signup. 6b needs to go through the same strategy as the Sessions controller — otherwise newly signed-up users aren’t recognized by the authenticator on the next request, and every protected page redirects them to /login.
-
Replace
app/controllers/Users.cfc:component extends="Controller" {function new() {user = model("User").new();}function create() {user = model("User").new(params.user);if (user.save()) {var sessionStrategy = application.wo.service("sessionStrategy");sessionStrategy.login(principal={id: user.id, email: user.email});redirectTo(route="posts", success="Welcome! You're signed up and logged in.");} else {renderView(action="new");}}}
One change from 6a: the post-save session.userId = user.id; line is replaced with a sessionStrategy.login({id, email}) call. Everything else is identical.
Rewrite the Posts filter
Section titled “Rewrite the Posts filter”-
Replace
app/controllers/Posts.cfc:component extends="Controller" {function config() {super.config();filters(through="authenticate", except="index,show");}function index() {posts = model("Post").published().findAll(include="comments", order="publishedAt DESC");}function show() {post = params.post;}function new() {post = model("Post").new();}function create() {post = model("Post").new(params.post);post.userId = $currentUserId();if (post.save()) {redirectTo(route="post", key=post.id);} else {renderPartial(partial="form", post=post, layout=false);}}function edit() {post = params.post;ownershipCheck(post);}function update() {post = params.post;ownershipCheck(post);if (post.update(params.post)) {redirectTo(route="post", key=post.id);} else {renderPartial(partial="form", post=post, layout=false);}}function delete() {post = params.post;ownershipCheck(post);post.delete();redirectTo(route="posts");}private function authenticate() {var result = application.wo.service("authenticator").authenticate(request);if (!result.success) {flashInsert(error="Please log in first");redirectTo(route="login");}}private function ownershipCheck(required any post) {if (arguments.post.userId != $currentUserId()) {redirectTo(route="posts");}}private function $currentUserId() {var result = application.wo.service("authenticator").authenticate(request);return result.success ? (result.principal.id ?: 0) : 0;}}
The authenticate filter now calls service("authenticator").authenticate(request). That method walks the registered strategies in order (just session for now) and returns a result struct — {success: true, principal: {...}} on success, {success: false} otherwise. The filter only cares about result.success.
$currentUserId() centralizes the “who am I” lookup. If you later add a JWT strategy, the helper keeps working without changes — result.principal.id is whatever the winning strategy decided to put there. The $ prefix is a Wheels convention that marks the method as internal to avoid colliding with framework helpers.
Compare
Section titled “Compare”| Piece | 6a (hand-rolled) | 6b (built-in) |
|---|---|---|
| Sessions controller | 18 lines, reads/writes session.userId directly | 14 lines, goes through sessionStrategy |
Posts authenticate filter | 4 lines, checks session.userId | 5 lines, asks the authenticator |
| Ownership check | reads session.userId inline | reads $currentUserId() |
| Adding JWT/API-token auth later | rewrite authenticate, create, delete, ownership check | register one more strategy in config/services.cfm |
The pure line count isn’t what matters. Look at the last row: 6b absorbs the next auth mechanism for free. That’s the win. For a small app that only does session auth, 6a is fine — maybe even preferable, because every line is right there on the page. But the instant someone says “we need mobile API access with bearer tokens,” 6b pays for itself.
Smoke test
Section titled “Smoke test”wheels --versionCheckpoint
Section titled “Checkpoint”Whether you stopped at 6a or continued to 6b, the user-facing behavior should be identical. Four things to verify in the browser:
- Signing up redirects you to
/posts, logged in. The “New post” link works. - Logging out clears the session. Visiting
/posts/newredirects you to/login. - Logging in again restores access. The session persists across page navigation.
- Trying to edit a post owned by a different user redirects you back to the posts index.
Troubleshooting
Section titled “Troubleshooting”“Login works, but every protected page redirects back to login.” The authenticate filter function is public instead of private. Public filter methods become routable actions — Wheels runs them through the action dispatcher and the filter body fires twice, and worse, a request like /posts/authenticate actually matches. Change the function authenticate() declaration to private function authenticate() and reload.
“The password always mismatches, even for a user I just created.” The beforeValidation callback isn’t firing, or the form is storing plaintext. Open the database and look at the users row directly: passwordHash should be exactly 64 uppercase hex characters, passwordSalt should be populated with a ~44-char base64 string. If either is blank, check that you named the callback correctly in config() (beforeValidation("hashPassword") — case-sensitive) and that the form submits user[password], not just password.
“6b: authenticator service not found.” Three things to check, in this order. (1) Does the file have a <cfscript> wrapper? Without it, Lucee treats the body as markup — you’ll see the bare local.di = injector(); lines printed at the top of every page after a cold restart, and the registration code never runs. Compare against config/settings.cfm if unsure of the shape. (2) Did the application actually restart? Both wheels reload and wheels stop && wheels start re-fire onApplicationStart and re-run config/services.cfm — but a reload with a wrong or missing reload password silently skips the restart. If in doubt, wheels stop && wheels start removes that variable. (CLI messages claiming wheels reload never re-fires onApplicationStart predate the #3110 fix.) (3) Component path typo? The .to(...) argument must be the exact dotted path — wheels.auth.Authenticator, not Authenticator — and the injector() call has to come first.
What’s next
Section titled “What’s next”Part 7 covers testing and deploying the blog. You’ll write your first model spec and controller spec using Wheels’ BDD syntax, then add one browser test that drives a real Chromium through the signup flow. The chapter ends with a high-level deployment overview — how Wheels apps go to production and what changes in config/production/settings.cfm.
The DI container you used to wire SessionStrategy in 6b is a core framework feature. See The Dependency Injection Container for when to reach for it vs plain new SomeClass().