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
| File | Role |
|---|---|
| Application.cfc | Session settings, the cfc mapping, and onError |
| index.cfm | Sends visitors to the dashboard or the login page |
| register.cfm | Signup form, checks email availability as you type |
| login.cfm | Login form, posts to ajax/loginCheck.cfm |
| dashboard.cfm | Protected list of all users, with delete buttons |
| profile.cfm | Protected profile form, posts to ajax/updateProfile.cfm |
| logout.cfm | Ends the session |
| ajax/registerCheck.cfm | Creates the user and stores the uploaded image |
| ajax/checkEmail.cfm | Returns taken or available for an email |
| ajax/loginCheck.cfm | Checks credentials and sets the session |
| ajax/updateProfile.cfm | Updates name, email, and image for the logged-in user |
| ajax/deleteUser.cfm | Deletes a user by id |
| cfc/Utils.cfc | Password hashing and verification |
| includes/header.cfm, footer.cfm | Shared layout |
| db/init.sql | The persons table, for SQL Server and MySQL |
| test.cfm | A 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.
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.
<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.
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;
}
}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.
<cfif session.loggedIn>
<cfset location("dashboard.cfm", true)>
</cfif><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>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.
<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><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.
<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.
<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
<cfscript>
// Destroy session
sessionInvalidate();
// Optional: clear client/session vars just in case
structClear(session);
// Redirect to login
location("login.cfm", true);
</cfscript>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.
| Finding | Where | Why it matters |
|---|---|---|
| Passwords hashed with unsalted SHA-256 and compared with == | cfc/Utils.cfc | Fast 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 checking | ajax/loginCheck.cfm, registerCheck.cfm | A password with a deliberate leading or trailing space stops matching what the user typed. |
| No sessionRotate() when logging in | ajax/loginCheck.cfm | A session ID planted before login stays valid afterward (session fixation). |
| Delete checks login but not ownership | ajax/deleteUser.cfm | Any 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 encoding | dashboard.cfm, profile.cfm | A 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 centrally | Application.cfc | onRequestStart does nothing. A new page without its own check is open to anyone. |
| Image saved before the size and duplicate checks | ajax/registerCheck.cfm | A rejected upload stays on disk, and anyone can fill the uploads folder. |
| Image size limit only on signup | ajax/updateProfile.cfm | The README promises a 2MB limit, but profile updates don't enforce it. |
| Public email lookup | ajax/checkEmail.cfm | Anyone can check whether an email is registered, one request at a time. |
| Errors are dumped to the browser | Application.cfc onError, ajax/registerCheck.cfm | writeDump shows internal details to the user. Log them on the server instead. |
| test.cfm is publicly reachable and broken | test.cfm | It has a typo (UtilsrateSecretKey) and calls generatePBKDF2Hash, which cfdocs.org can't find. Delete it. |
| Login query uses TOP 1, a SQL Server-only keyword | ajax/loginCheck.cfm | db/init.sql also defines MySQL, and this query fails there. Email is UNIQUE, so the TOP 1 isn't needed at all. |
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.
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);
}
}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>');
}// 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;
}<td><img src="uploads/#encodeForHTMLAttribute(profile_image)#" width="50" height="50" class="rounded-circle"></td> <td>#encodeForHTML(name)#</td> <td>#encodeForHTML(email)#</td>
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);
}
}function onError(exception, eventName) {
cflog(file = "app", type = "error", text = "#exception.message# | #exception.detail#");
writeOutput("Something went wrong. Please try again.");
}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.
- Adobe — Hash and Verify Passwords in ColdFusion ↗
- cfdocs.org — sessionRotate ↗
- cfdocs.org — sessionInvalidate ↗
- cfdocs.org — Application.cfc ↗
- devlearningtools — Password Hashing ↗
- devlearningtools — Secure Sessions ↗
- devlearningtools — XSS Prevention ↗
- devlearningtools — SQL Injection Prevention ↗
- devlearningtools — Authentication Basics ↗