DevLearningTools

MODULE 18 · LESSON 03

Login System

A complete ColdFusion login and signup application, walked through file by file from its real source: registration with image upload, login, protected pages, profile updates, and logout, followed by a security review of the same code.

This is a full application, not a set of snippets. Every code block below is copied from the project's real source, and each file is followed by what it does and what needs fixing. The project is a signup and login system with an image upload, a user list, profile editing, and deletion, all driven by AJAX.

Learning Objectives

After working through this project, you'll be able to:

  • Trace a request from a form, through an AJAX handler, into a CFC and a query.
  • Explain how session.loggedIn guards pages and why the guard has to be on every page that needs it.
  • Read a password check and identify what makes it weak.
  • Find the authorization gap in a delete action that only checks login, not ownership.
  • Apply the Module 15 fixes to the real code.

How the Application Fits Together

Browser Submits a Form

register.cfm or login.cfm, via fetch()

AJAX Handler Runs

ajax/*.cfm does the real work

CFC and Query Touch the Data

cfc/Utils.cfc, queryExecute

Session Decides Access

session.loggedIn on each protected page

The Project's Files

FileRole
Application.cfcSession settings, the cfc mapping, and onError
index.cfmSends visitors to the dashboard or the login page
register.cfmSignup form, checks email availability as you type
login.cfmLogin form, posts to ajax/loginCheck.cfm
dashboard.cfmProtected list of all users, with delete buttons
profile.cfmProtected profile form, posts to ajax/updateProfile.cfm
logout.cfmEnds the session
ajax/registerCheck.cfmCreates the user and stores the uploaded image
ajax/checkEmail.cfmReturns taken or available for an email
ajax/loginCheck.cfmChecks credentials and sets the session
ajax/updateProfile.cfmUpdates name, email, and image for the logged-in user
ajax/deleteUser.cfmDeletes a user by id
cfc/Utils.cfcPassword hashing and verification
includes/header.cfm, footer.cfmShared layout
db/init.sqlThe persons table, for SQL Server and MySQL
test.cfmA leftover experiment page, see the audit

Step 1: Application.cfc

Application.cfc turns on sessions with a one-hour timeout and sets session.loggedIn to false for every new visitor. onRequestStart runs before every page, but it contains no login check, so each protected page has to make its own.

Application.cfc
component output="false" {

    this.name = "coldfusion-login-signup-crud-image";
    this.sessionManagement = true;
    this.sessionTimeout = createTimeSpan(0,1,0,0);
    //this.datasource = "localDB"; 

    this.mappings["/cfc"] = expandPath("./cfc");

    function onApplicationStart() {
        application.appName = "ColdFusion AJAX User App";
        application.datasource = "localDB"; // Set your datasource name here
        return true;
    }

    function onSessionStart() {
        session.loggedIn = false;
    }

    function onRequestStart(targetPage) {
        //include "includes/functions.cfm";
        onApplicationStart();
    }

    function onError(exception, eventName) {
        writeDump(exception);
        abort;
    }

}

Step 2: Signup, the AJAX Handler

register.cfm posts the form to ajax/registerCheck.cfm. The handler saves the image first, then checks that the email isn't already taken, then inserts the row with the password hashed. Read the order carefully, it matters for the audit.

ajax/registerCheck.cfm
<cfscript>
    utils = new "coldfusion-login-signup-crud-image.cfc.Utils"();  

    name = trim(form.name ?: "");
    email = trim(form.email ?: "");
    password = trim(form.password ?: "");
    imageField = "profileImage";
    uploadPath = expandPath("../uploads/");

    if (len(email) && len(password) && structKeyExists(form, imageField)) {

        // Ensure upload folder exists
        if (!directoryExists(uploadPath)) {
            directoryCreate(uploadPath);
        }

        // Process Image Upload
        uploadedImage = fileUpload(
            destination = uploadPath,
            fileField = imageField,
            onConflict = "makeUnique",
            mimeType = "image/jpeg,image/png",
            strict = true
        );

        // Extract original file name and extension
        originalName = uploadedImage.clientFile; // e.g., "photo.jpg"
        baseName = listFirst(originalName, "."); // "photo"
        ext = listLast(originalName, ".");       // "jpg"

        // Append random number to base name
        randomSuffix = randRange(10000, 99999); // e.g., 5-digit random
        imageName = baseName & "_" & randomSuffix & "." & ext; // "photo_87236.jpg"

        // Rename uploaded file to new image name
        fileMove(uploadPath & uploadedImage.serverFile, uploadPath & imageName);
        //imageName = uploadedImage.serverFile; // get uploaded filename

        // Validate image size (max 2MB)
        if (uploadedImage.filesize > 2097152) {
            fileDelete(uploadPath & imageName);
            writeOutput('<div class="alert alert-danger">Image too large (max 2MB).</div>');
            abort;
        }

        hashedPass = utils.hashPassword(password);

        // Check if email already exists (SERVER-SIDE VALIDATION)
        checkEmail = queryExecute("
            SELECT COUNT(*) AS total FROM persons WHERE email = :email
        ", {
            email: { value: email, cfsqltype: "cf_sql_varchar" }
        }, { datasource = application.datasource });

        if (checkEmail.total > 0) {
            writeOutput('<div class="alert alert-danger">Email already exists. Try another one.</div>');
            abort;
        }

        try {
            queryExecute("
                INSERT INTO persons (name, email, password, profile_image)
                VALUES (:name, :email, :pass, :image)
            ", {
                name:   { value: name, cfsqltype: "cf_sql_varchar" },
                email:  { value: email, cfsqltype: "cf_sql_varchar" },
                pass:   { value: hashedPass, cfsqltype: "cf_sql_varchar" },
                image:  { value: imageName, cfsqltype: "cf_sql_varchar" }
            }, { datasource = application.datasource });

            writeOutput('<div class="alert alert-success">User registered with image!</div>');
        } catch (any e) {
            writeDump(e);
            abort;
            writeOutput('<div class="alert alert-danger">DB Error: #e.message#</div>');
        }

    } else {
        writeOutput('<div class="alert alert-danger">All fields including image are required!</div>');
    }
</cfscript>

Step 3: The Password Helper

Utils.cfc is the only place passwords are hashed and checked. Both functions call hash() with SHA-256 directly.

cfc/Utils.cfc
component accessors="true" {

    /**
     * Hash password using SHA-256
     */
    public string function hashPassword(required string plainText) {
        return hash(plainText, "SHA-256");
    }

    /**
     * Check password match (optional for login)
     */
    public boolean function verifyPassword(required string plainText, required string hashed) {
        return hash(plainText, "SHA-256") == hashed;
    }

}
NOTE

hash() with SHA-256 is fast and has no salt. The Password Hashing lesson explains why that's unsafe for passwords, and the fix is in the Fixes section below.

Step 4: Login

login.cfm redirects logged-in users to the dashboard, then posts the form to ajax/loginCheck.cfm. The handler looks the user up by email, verifies the password, and sets the session variables.

login.cfm (top of file)
<cfif session.loggedIn>
    <cfset location("dashboard.cfm", true)>
</cfif>
ajax/loginCheck.cfm
<cfsetting showdebugoutput="false">
<cfcontent type="text/html">
<cfscript>
    utils = new "coldfusion-login-signup-crud-image.cfc.Utils"();

    email = trim(form.email ?: "");
    password = trim(form.password ?: "");

    if (len(email) && len(password)) {
        user = queryExecute("
            SELECT TOP 1 id, name, email, password
            FROM persons
            WHERE email = :email
        ", {
            email: { value: email, cfsqltype: "cf_sql_varchar" }
        }, { datasource = application.datasource });

        if (user.recordCount == 1 && utils.verifyPassword(password, user.password)) {
            session.loggedIn = true;
            session.userID = user.id;
            session.userName = user.name;
            session.email = user.email;

            writeOutput('<div class="alert alert-success">Login success! Redirecting...</div>');
        } else {
            writeOutput('<div class="alert alert-danger">Invalid email or password</div>');
        }
    } else {
        writeOutput('<div class="alert alert-warning">Please fill in both fields.</div>');
    }
</cfscript>
NOTE

The lookup uses cfqueryparam and returns the same message for a wrong email and a wrong password, both good. The session is set without first calling sessionRotate(), see the audit.

Step 5: Protected Pages

Each protected page starts with the same check. dashboard.cfm shows the list of users. The output below is the part of the page that prints the data, the JavaScript is left out here.

dashboard.cfm (guard and table, JavaScript omitted)
<cfif NOT structKeyExists(session, "loggedIn") OR NOT session.loggedIn>
    <cfset location("login.cfm", true)>
</cfif>

<cfinclude template="includes/header.cfm">

<h3 class="mb-3">All Registered Users</h3>

<table id="usersTable" class="table table-striped table-bordered">
    <thead>
        <tr>
            <th>Image</th>
            <th>Name</th>
            <th>Email</th>
            <th>Actions</th>
        </tr>
    </thead>
    <tbody>
        <cfquery name="allUsers" datasource="#application.datasource#">
            SELECT id, name, email, profile_image FROM persons ORDER BY id DESC
        </cfquery>

        <cfoutput query="allUsers">
            <tr id="userRow_#id#">
                <td><img src="uploads/#profile_image#" width="50" height="50" class="rounded-circle"></td>
                <td>
                    #name#
                    <cfif id EQ session.userID>
                        <a href="profile.cfm" class="badge bg-primary text-white text-decoration-none ms-2">Own</a>
                    </cfif>
                </td>
                <td>#email#</td>
                <td>
                    <cfif id EQ session.userID>
                        <button class="btn btn-sm btn-secondary" disabled>Delete</button>
                    <cfelse>
                        <button class="btn btn-sm btn-danger" onclick="deleteUser(#id#)">Delete</button>
                    </cfif>
                </td>
            </tr>
        </cfoutput>
    </tbody>
</table>

<div id="deleteMsg"></div>
profile.cfm (form, JavaScript omitted)
<cfif NOT structKeyExists(session, "loggedIn") OR NOT session.loggedIn>
    <cfset location("login.cfm", true)>
</cfif>

<cfinclude template="includes/header.cfm">

<h3>My Profile</h3>

<cfquery name="userInfo" datasource="#application.datasource#">
    SELECT id, name, email, profile_image
    FROM persons
    WHERE id = <cfqueryparam value="#session.userID#" cfsqltype="cf_sql_integer">
</cfquery>

<cfoutput query="userInfo">
    <form id="profileForm" enctype="multipart/form-data">
        <input type="hidden" name="id" value="#id#">

        <div class="mb-2">
            <label>Name</label>
            <input type="text" name="name" class="form-control" value="#name#" disabled required>
        </div>
        <div class="mb-2">
            <label>Email</label>
            <input type="email" name="email" class="form-control" id="email" value="#email#" disabled required>
            <span id="emailCheck" class="form-text"></span>
        </div>
        <div class="mb-2">
            <label>Profile Image</label><br>
            <img id="profilePreview" src="uploads/#profile_image#" width="100" class="rounded mb-2">
            <input type="file" name="profileImage" class="form-control" accept="image/*" disabled>
        </div>

        <div class="d-flex gap-2">
            <button type="button" id="editBtn" class="btn btn-primary">Edit</button>
            <button type="submit" id="updateBtn" class="btn btn-success" disabled>Update</button>
        </div>
    </form>

    <div id="updateResult" class="mt-3"></div>
</cfoutput>

Step 6: Updating a Profile

updateProfile.cfm uses session.userID, not a form field, to decide whose row to update, so a user can only edit their own profile. It replaces the image only when a new file is sent.

ajax/updateProfile.cfm
<cfsetting showdebugoutput="false">
<cfcontent type="text/html">
<cfscript>

    response = {
        status: "success",
        message: "Profile updated successfully.",
        newImage: ""
    };

    if (!structKeyExists(session, "loggedIn") || !session.loggedIn) {
        response.status = "error";
        response.message = "Unauthorized";
        writeOutput(serializeJSON(response));
        abort;
    }

    id = session.userID;
    name = trim(form.name ?: "");
    email = trim(form.email ?: "");
    imageField = "profileImage";
    uploadPath = expandPath("../uploads/");
    imageChanged = false;

    if (structKeyExists(form, imageField) && len(form[imageField])) {
        imageChanged = true;
    }

    if (len(name) && len(email)) {

        if (imageChanged) {
            oldImageQuery = queryExecute("
                SELECT profile_image FROM persons WHERE id = :id
            ", { id: { value: id, cfsqltype: "cf_sql_integer" } },
            { datasource = application.datasource });

            oldImage = oldImageQuery.recordCount ? oldImageQuery.profile_image : "";

            if (!directoryExists(uploadPath)) {
                directoryCreate(uploadPath);
            }

            uploadedImage = fileUpload(
                destination = uploadPath,
                fileField = imageField,
                onConflict = "makeUnique",
                mimeType = "image/jpeg,image/png",
                strict = true
            );
            // Extract original file name and extension
            originalName = uploadedImage.clientFile; // e.g., "photo.jpg"
            baseName = listFirst(originalName, "."); // "photo"
            ext = listLast(originalName, ".");       // "jpg"

            // Append random number to base name
            randomSuffix = randRange(10000, 99999); // e.g., 5-digit random
            newImage = baseName & "_" & randomSuffix & "." & ext; // "photo_87236.jpg"

            // Rename uploaded file to new image name
            fileMove(uploadPath & uploadedImage.serverFile, uploadPath & newImage);
            //newImage = uploadedImage.serverFile;
            response.newImage = newImage;

            if (len(oldImage)) {
                oldImagePath = uploadPath & oldImage;
                if (fileExists(oldImagePath)) fileDelete(oldImagePath);
            }

            queryExecute("
                UPDATE persons
                SET name = :name, email = :email, profile_image = :img
                WHERE id = :id
            ", {
                name: { value: name, cfsqltype: "cf_sql_varchar" },
                email: { value: email, cfsqltype: "cf_sql_varchar" },
                img: { value: newImage, cfsqltype: "cf_sql_varchar" },
                id: { value: id, cfsqltype: "cf_sql_integer" }
            }, { datasource = application.datasource });

        } else {
            queryExecute("
                UPDATE persons
                SET name = :name, email = :email
                WHERE id = :id
            ", {
                name: { value: name, cfsqltype: "cf_sql_varchar" },
                email: { value: email, cfsqltype: "cf_sql_varchar" },
                id: { value: id, cfsqltype: "cf_sql_integer" }
            }, { datasource = application.datasource });
        }

        session.userName = name;
        session.email = email;

    } else {
        response.status = "error";
        response.message = "Missing required fields.";
    }

    writeOutput(serializeJSON(response));

</cfscript>

Step 7: Deleting a User

deleteUser.cfm checks that the caller is logged in, then deletes the user whose id was posted. Read it with the audit in mind: the check is for login, and nothing else.

ajax/deleteUser.cfm
<cfsetting showdebugoutput="false">
<cfcontent type="text/html">

<cfif NOT structKeyExists(session, "loggedIn") OR NOT session.loggedIn>
    <cfoutput><div class="alert alert-danger">Unauthorized access.</div></cfoutput>
    <cfabort>
</cfif>

<cfscript>
    id = val(form.id ?: 0);

    if (id > 0) {
        // Get image name first to delete file
        result = queryExecute("
            SELECT profile_image FROM persons WHERE id = :id
        ", { id: { value: id, cfsqltype: "cf_sql_integer" } }, 
        { datasource = application.datasource });

        if (result.recordCount) {
            imageFile = expandPath("../uploads/" & result.profile_image);
            if (fileExists(imageFile)) fileDelete(imageFile);
        }

        // Delete from DB
        queryExecute("DELETE FROM persons WHERE id = :id", {
            id: { value: id, cfsqltype: "cf_sql_integer" }
        }, { datasource = application.datasource });

        writeOutput('<div class="alert alert-success">User deleted.</div>');
    } else {
        writeOutput('<div class="alert alert-warning">Invalid user ID.</div>');
    }
</cfscript>

Step 8: Logout

logout.cfm
<cfscript>
    // Destroy session
    sessionInvalidate();

    // Optional: clear client/session vars just in case
    structClear(session);

    // Redirect to login
    location("login.cfm", true);
</cfscript>
NOTE

Logout ends the session completely: sessionInvalidate() retires the session ID, then structClear() empties the scope, as the Secure Sessions lesson describes.

Security Review of This Code

Each finding below points at the exact file it's in. The README describes several protections, so each one is checked against the code.

FindingWhereWhy it matters
Passwords hashed with unsalted SHA-256 and compared with ==cfc/Utils.cfcFast and unsalted, so a leaked database can be cracked quickly. The README says PBKDF2 is used, but the code doesn't do that.
Password is trimmed before hashing and checkingajax/loginCheck.cfm, registerCheck.cfmA password with a deliberate leading or trailing space stops matching what the user typed.
No sessionRotate() when logging inajax/loginCheck.cfmA session ID planted before login stays valid afterward (session fixation).
Delete checks login but not ownershipajax/deleteUser.cfmAny logged-in user can delete any other user. The dashboard hides the button on your own row, but that's only in the browser.
Names and emails printed without encodingdashboard.cfm, profile.cfmA name containing script runs for everyone who views the list (stored XSS). profile.cfm also puts the name inside an input's value attribute, where an attacker can break out of the quotes.
Protection isn't enforced centrallyApplication.cfconRequestStart does nothing. A new page without its own check is open to anyone.
Image saved before the size and duplicate checksajax/registerCheck.cfmA rejected upload stays on disk, and anyone can fill the uploads folder.
Image size limit only on signupajax/updateProfile.cfmThe README promises a 2MB limit, but profile updates don't enforce it.
Public email lookupajax/checkEmail.cfmAnyone can check whether an email is registered, one request at a time.
Errors are dumped to the browserApplication.cfc onError, ajax/registerCheck.cfmwriteDump shows internal details to the user. Log them on the server instead.
test.cfm is publicly reachable and brokentest.cfmIt has a typo (UtilsrateSecretKey) and calls generatePBKDF2Hash, which cfdocs.org can't find. Delete it.
Login query uses TOP 1, a SQL Server-only keywordajax/loginCheck.cfmdb/init.sql also defines MySQL, and this query fails there. Email is UNIQUE, so the TOP 1 isn't needed at all.
NOTE

Things the project already does well: every query uses cfqueryparam, the login failure message is generic, the protected pages check the session, and updateProfile only ever touches session.userID.

Fixes

The changes below apply to the real files. Hashing first: Adobe ColdFusion 2025 Update 8 and later provide PasswordHashGenerate() and PasswordHashVerify(). Existing rows hashed with SHA-256 can't be verified with the new function, so check the old hash on the next successful login and store the new hash then.

cfc/Utils.cfc (fixed)
component accessors="true" {

    public string function hashPassword(required string plainText) {
        return passwordHashGenerate(arguments.plainText);
    }

    public boolean function verifyPassword(required string plainText, required string hashed) {
        return passwordHashVerify(arguments.plainText, arguments.hashed);
    }

}
ajax/loginCheck.cfm (fixed success branch)
if (user.recordCount == 1 AND utils.verifyPassword(password, user.password)) {
    sessionRotate();
    session.loggedIn = true;
    session.userID = user.id;
    session.userName = user.name;
    session.email = user.email;
    writeOutput('<div class="alert alert-success">Login success! Redirecting...</div>');
} else {
    writeOutput('<div class="alert alert-danger">Invalid email or password</div>');
}
ajax/deleteUser.cfm (fixed guard, added after the login check)
// Only the owner can delete their own account, a server-side check
if (id NEQ session.userID) {
    writeOutput('<div class="alert alert-danger">You can only delete your own account.</div>');
    abort;
}
dashboard.cfm (fixed output)
<td><img src="uploads/#encodeForHTMLAttribute(profile_image)#" width="50" height="50" class="rounded-circle"></td>
<td>#encodeForHTML(name)#</td>
<td>#encodeForHTML(email)#</td>
Application.cfc (central guard, fixed onRequestStart)
function onRequestStart(targetPage) {
    var publicPages = "index.cfm,login.cfm,register.cfm,logout.cfm,loginCheck.cfm,registerCheck.cfm,checkEmail.cfm";
    var page = listLast(arguments.targetPage, "/");
    if (NOT listFindNoCase(publicPages, page) AND NOT (structKeyExists(session, "loggedIn") AND session.loggedIn)) {
        location(url = "login.cfm", addToken = false);
    }
}
Application.cfc (onError, log instead of dump)
function onError(exception, eventName) {
    cflog(file = "app", type = "error", text = "#exception.message# | #exception.detail#");
    writeOutput("Something went wrong. Please try again.");
}
NOTE

Two items are deliberately left for you to check on your own server rather than assumed. First, whether the JSON keys in updateProfile.cfm (status, message, newImage) keep their case through serializeJSON, because the page's JavaScript reads them as STATUS, MESSAGE, and NEWIMAGE. Second, whether this app's onRequestStart is ever reached with a targetPage that includes a folder, which the listLast() approach depends on.

Common Beginner Mistakes

Checking that someone is logged in and assuming that's enough

Login tells you who the caller is, not what they're allowed to change. deleteUser.cfm shows the gap: any logged-in user can delete any other user. Check ownership or a role on the server.

Hiding a button in the browser and treating that as protection

The dashboard hides Delete on your own row, but the request can still be sent by hand. The rule has to be enforced in the handler.

Printing user data without encoding because it came from your own database

Anything a user can type ends up in the database, so it's user input. Encode it wherever it's printed.

Interview Questions

What's the difference between authentication and authorization in this app?

Authentication is the session check that says who the caller is. Authorization is the ownership check in deleteUser.cfm that says whether they may delete that particular user. This project has the first and is missing the second.

Why is sessionRotate() needed in the login handler?

Without it, a session ID an attacker planted before login stays valid after the user authenticates, so the attacker inherits the logged-in session.

Summary

You walked through the whole application: signup, login, the session guard, profile updates, deletion, and logout, all from the real source. The most important lesson is in the review: the code does several things correctly, and the gaps are in the places where a check exists but doesn't go far enough.