Skip to content

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 session scope
  • A private authenticate filter that protects sensitive actions
  • Wheels’ built-in wheels.auth.SessionStrategy — and when to prefer it

Estimated time: 45 minutes.

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

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).


  1. 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 when this.password is set — that is, when the caller just assigned a new password. It generates a per-user salt if one isn’t already stored, hashes password + salt with SHA-256, stashes the result in this.passwordHash, and scrubs this.password so the plaintext never reaches the database. The callback runs before validation (rather than beforeSave) because Wheels’ automatic validatesPresenceOf rules treat the NOT NULL passwordHash and passwordSalt columns as required — they need to be populated by the time validation fires, otherwise every signup fails with Passwordhash can't be empty.
  • Hash(input, "SHA-256") returns uppercase hex — 64 characters. That’s why the migration below reserves 64 chars for passwordHash.
  • authenticate(password) re-hashes the candidate password with the stored salt and compares. Equal means the user typed the right password.
  1. 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).

  1. 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”
  1. 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:

  • new renders the login form. No setup needed; the view uses no dynamic data.
  • create is the login POST. It normalizes the submitted email the same way the model does on save, looks up the user via the dynamic finder findOneByEmail — Wheels generates findOneBy<Property> for every column, and the value is bound as a parameter so this is injection-safe. Then it calls user.authenticate(password). Success sets session.userId and redirects; failure flashes an error and sends the browser back to the login form.
  • delete is the logout action. It clears session.userId and redirects.
  1. 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.

  1. 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.

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.

  1. 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);
    }
    }
  2. Update app/models/User.cfc to 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.

  1. 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.

  1. 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>
  2. 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.

Apply the migrations:

your shell
wheels migrate latest

Then walk through the flow in the browser:

  1. Visit /signup, create an account with any email and password. You’re redirected to /posts and logged in.
  2. Click “New post” — it works. The newly-created post has post.userId set from session.userId.
  3. Submit a logout form, then try /posts/new — you’re redirected to /login.
  4. 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:

app/views/layout.cfm (excerpt)
#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.


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.

  1. 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:

app/events/onapplicationstart.cfm
<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.)

  1. 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.

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.

  1. 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.

  1. 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.

Piece6a (hand-rolled)6b (built-in)
Sessions controller18 lines, reads/writes session.userId directly14 lines, goes through sessionStrategy
Posts authenticate filter4 lines, checks session.userId5 lines, asks the authenticator
Ownership checkreads session.userId inlinereads $currentUserId()
Adding JWT/API-token auth laterrewrite authenticate, create, delete, ownership checkregister 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.

Terminal window
wheels --version

Whether you stopped at 6a or continued to 6b, the user-facing behavior should be identical. Four things to verify in the browser:

  1. Signing up redirects you to /posts, logged in. The “New post” link works.
  2. Logging out clears the session. Visiting /posts/new redirects you to /login.
  3. Logging in again restores access. The session persists across page navigation.
  4. Trying to edit a post owned by a different user redirects you back to the posts index.

“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.

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().