This is a complete second-factor flow: a user enrolls by scanning a QR code into an authenticator app, then enters a six-digit code at login. Every code block is copied from the project's real source. The review that follows finds the most serious problem in this whole set of examples, so read it carefully.
Learning Objectives
After working through this project, you'll be able to:
- Trace a TOTP enrollment from the form, through the database, to the QR code.
- Follow how validateTOTP checks a submitted code against a time window.
- Find a case where the server trusts a value the client supplies.
- Explain why replay protection and encrypted storage matter for a second factor.
How the Flow Fits Together
Enter an Email
generateSecret.cfm
Enroll or Look Up
INSERT the secret, or read it back
Show the QR or Ask for a Code
ZXing draws the QR for the app
Validate the Code
validateTOTP(secret, code)
The Project's Files
| File / Folder | Role |
|---|---|
| generateSecret.cfm | Enrollment and verification: asks for an email, then either shows a QR or asks for a code |
| validateTotp.cfm | The TOTP functions: random secret, code computation, and validation |
| lib/zxing-2.1-core.jar, zxing-2.1-javase.jar | QR code generation |
| README.md | Setup notes and the SQL Server table definition |
Setup: Which JARs to Put in lib, and Where
You don't download a separate JDK. ColdFusion and Lucee already run on a Java Virtual Machine, so the only thing to add is the libraries these pages import. There are two kinds.
1. ZXing, the barcode library that draws the QR code the authenticator app scans. It comes as two JARs: zxing-2.1-core.jar (the encoder) and zxing-2.1-javase.jar (converts the result to a PNG). Both are public and free, and you get them from Maven Central, the standard repository for Java libraries. Then place them in the lib folder described below.
To download them in a browser: open https://repo1.maven.org/maven2/com/google/zxing/core/2.1/ and click core-2.1.jar to download it. Then open https://repo1.maven.org/maven2/com/google/zxing/javase/2.1/ and click javase-2.1.jar. Browsers save both files to your Downloads folder by default, so move them into the lib folder. Check the sizes: core-2.1.jar is about 450 KB and javase-2.1.jar is about 35 KB. A file of only a few KB, or one that opens as text starting with an HTML tag, means a web page was saved by mistake, so download it again.
2. Apache Commons Codec, for the Base32 class that validateTotp.cfm imports as org.apache.commons.codec.binary.Base32. The repo doesn't include it. Download the binary JAR from the Apache Commons Codec project (the current release listed on commons.apache.org is 1.22.1). Confirm the JAR contains the org/apache/commons/codec/binary/Base32 class before copying it.
Then place the JARs in one of these locations, and restart ColdFusion or Lucee. Restarting matters, the server only loads JARs at startup.
| Server | Server-wide lib folder (from the README) | Project-local option |
|---|---|---|
| Adobe ColdFusion | C:\ColdFusion2025\cfusion\lib | Project lib/ folder under the webroot, as the README shows |
| Lucee | C:\lucee\tomcat\lib | Project lib/ folder under the webroot, as the README shows |
The server-wide folder makes the JARs visible to every application on that server. The project-local lib/ folder keeps them with this one project, which is easier to remove later. Either works, the requirement is only that the JARs are on the server's classpath when it starts.
Step 1: validateTOTP and the Code Computation
validateTotp.cfm defines the functions used by generateSecret.cfm. validateTOTP computes the expected code for the current 30-second step and for the window before it, then compares each one with the submitted code. getOneTimeToken is the standard HMAC-SHA1 computation from RFC 6238.
<cfscript>
/**
* Initialize SecureRandom and Base32 codec
*/
variables.generator = createObject("java", "java.security.SecureRandom").getInstance("SHA1PRNG");
variables.base32 = createObject("java", "org.apache.commons.codec.binary.Base32").init();
variables.alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
/**
* Validate a 6-digit TOTP code
*/
public boolean function validateTOTP(required string secret, required string code, numeric window = 1) {
var step = JavaCast("long", createObject("java", "java.lang.System").currentTimeMillis() / 1000 / 30);
for (var i = 0; i <= arguments.window; i++) {
if (getOneTimeToken(arguments.secret, step - i) == arguments.code) {
return true;
}
}
return false;
}
/**
* Compute TOTP code for a given counter
*/
private string function getOneTimeToken(required string base32Secret, required numeric counter) {
var key = variables.base32.decode(base32Secret);
var mac = createObject("java", "javax.crypto.Mac").getInstance("HmacSHA1");
mac.init(createObject("java", "javax.crypto.spec.SecretKeySpec").init(key, "HmacSHA1"));
var buf = createObject("java", "java.nio.ByteBuffer").allocate(8);
buf.putLong(arguments.counter);
var hmacBytes = mac.doFinal(buf.array());
var offset = bitAnd(hmacBytes[arrayLen(hmacBytes)], 15);
var binary = bitOr(bitOr(bitSHLN(bitAnd(hmacBytes[offset + 1], 127), 24), bitSHLN(bitAnd(hmacBytes[offset + 2], 255), 16)),
bitOr(bitSHLN(bitAnd(hmacBytes[offset + 3], 255), 8), bitAnd(hmacBytes[offset + 4], 255)));
var otp = binary mod 1000000;
return numberFormat(otp, "000000");
}
</cfscript>validateTotp.cfm uses org.apache.commons.codec's Base32 class. The project's lib folder doesn't include commons-codec, so check that your server provides it, or the first call will fail.
Step 2: Enrollment and Verification
generateSecret.cfm handles three cases, depending on which form fields arrive. With no fields, it shows the email form. With an email, it looks the user up: if a secret exists, it shows a code form that carries the secret in a hidden field; if not, it generates a secret, saves it, and shows the QR code. With a code, it validates it.
<cfinclude template="validateTotp.cfm" />
<cfscript>
// SQL SERVER Table Creation
/**
CREATE TABLE usersdata (
id INT IDENTITY(1,1) PRIMARY KEY,
email VARCHAR(255) NOT NULL UNIQUE,
totp_secret VARCHAR(255) DEFAULT NULL,
uri VARCHAR(255) DEFAULT NULL
);
*/
// Initialize database connection (using queryExecute directly)
datasource = "localDB";
/**
* Generate a new Base32 secret key
* @param length Desired length of secret key (default is 20 characters)
*/
function generateTOTPSecret(numeric length = 20) {
variables.secret = "";
variables.alphaLen = len(variables.alphabet);
for (variables.i = 1; i <= arguments.length; i++) {
variables.idx = variables.generator.nextInt(alphaLen);
secret &= mid(variables.alphabet, idx + 1, 1);
}
return secret;
}
/**
* Generate a PNG QR code for Google Authenticator using ZXing
* @return Path to the saved QR code file
*/
public binary function generateQRCode(required string secret,
required string accountName,
string issuer = "",
numeric width = 200,
numeric height = 200) {
// Build the otpauth URL
var label = URLEncodedFormat(arguments.accountName);
var secretGenerated = arguments.secret;
var userEmail = arguments.accountName;
var issuerParam = len(arguments.issuer) ? URLEncodedFormat(arguments.issuer) & ":" : "";
var paramString = "secret=" & arguments.secret & (len(arguments.issuer) ? "&issuer=" & URLEncodedFormat(arguments.issuer) : "");
var otpauthURL = "otpauth://totp/" & issuerParam & label & "?" & paramString;
// Save to database using queryExecute
sql = "INSERT INTO usersdata (email, totp_secret, uri) VALUES (:userEmail, :secretGenerated, :otpauthURL)";
params = {
userEmail = userEmail,
secretGenerated = secretGenerated,
otpauthURL = otpauthURL
};
queryExecute(sql, params, {datasource: datasource});
// Use ZXing classes (ensure zxing-core and zxing-javase JARs are in the CF lib folder)
var QRCodeWriter = createObject("java", "com.google.zxing.qrcode.QRCodeWriter");
var BarcodeFormat = createObject("java", "com.google.zxing.BarcodeFormat");
var bitMatrix = QRCodeWriter.encode(otpauthURL, BarcodeFormat.QR_CODE, arguments.width, arguments.height);
var baos = createObject("java", "java.io.ByteArrayOutputStream").init();
createObject("java", "com.google.zxing.client.j2se.MatrixToImageWriter").writeToStream(bitMatrix, "PNG", baos);
return baos.toByteArray();
}
// Main form handling logic
if (!structKeyExists(FORM, "userEmail") && !structKeyExists(FORM, "totpCode")) {
writeOutput('<form method="POST">
<label for="userEmail">Enter your email to generate:</label>
<input type="email" name="userEmail" required>
<button type="submit">Generate</button>
</form>');
} else if (structKeyExists(FORM, "userEmail")) {
variables.userEmail = FORM.userEmail;
try {
// Fetch existing secret using queryExecute
sql = "SELECT totp_secret FROM usersdata WHERE email = :userEmail";
params = {userEmail = userEmail};
result = queryExecute(sql, params, {datasource: datasource});
if (result.RecordCount > 0) {
// Secret exists, prompt for validation
writeOutput('<form method="POST">
<label for="totpCode">Enter your TOTP code:</label>
<input type="text" name="totpCode" required>
<input type="hidden" name="secret" value="#result.totp_secret[1]#">
<button type="submit">Verify</button>
</form>');
} else {
// Generate and save QR code for new user
variables.totpSecret = generateTOTPSecret();
variables.qrImageBytes = generateQRCode(secret=totpSecret, accountName=userEmail, issuer="gttApp");
writeOutput('<h3>You are not setup MFA yet, So please scan this QR Code with your Authenticator App:</h3>');
writeOutput('<img src="data:image/png;base64,#toBase64(variables.qrImageBytes)#" alt="QR Code">');
writeOutput('<h3>Enter Secret Manually: #totpSecret#</h3>');
}
} catch (any e) {
writeOutput("Error: " & e.message);
}
} else if (structKeyExists(FORM, "totpCode")) {
variables.valid = validateTOTP(FORM.secret, FORM.totpCode);
variables.message = valid ? "Authentication successful!" : "Invalid TOTP code.";
writeOutput("<h3>#message#</h3>");
}
</cfscript>A Critical Finding: The Server Trusts the Secret It's Given Back
Look at the verification branch. The page renders the user's stored secret into a hidden field, then the verification step calls validateTOTP(FORM.secret, FORM.totpCode). The secret it checks against comes from the request, not from the database.
<input type="hidden" name="secret" value="#result.totp_secret[1]#">
variables.valid = validateTOTP(FORM.secret, FORM.totpCode);
Two consequences. Anyone who enters a victim's email gets that user's secret in the page source, and can then compute valid codes forever. And even without that, any caller can post their own secret together with a code computed from it, and the page returns 'Authentication successful!' Nothing ties the check to the stored secret.
A Correction to an Earlier Version of This Page
An earlier draft of this lesson said that enrollment lets anyone overwrite another user's secret. The code doesn't do that. When a user already exists, the page shows the code form instead of generating a new secret, and email is UNIQUE, so a second insert would fail. The real problem is the one above, which is worse.
Other Findings
| Finding | Where | Why it matters |
|---|---|---|
| Secret stored in plaintext | usersdata.totp_secret, the INSERT | Anyone who reads the table can generate valid codes for every enrolled user. A TOTP secret has to be recoverable, so encrypt it, don't hash it. |
| No replay protection | validateTOTP | A code is accepted again for as long as its window is open. RFC 6238 says a verifier must not accept the same code twice. |
| Verification never identifies the user | generateSecret.cfm | No email or session is checked at the code step, so a success doesn't say whose account was verified. |
| Exception message shown to the user | generateSecret.cfm catch block | writeOutput("Error: " & e.message) can reveal database details. |
| Success creates no session | generateSecret.cfm | The page prints a message. Nothing marks a login as complete, so this isn't yet a second factor for real logins. |
| No authentication on enrollment | generateSecret.cfm | Anyone can enroll an email that isn't enrolled yet. That's expected for the first enrollment, but it has to be tied to an authenticated account in a real app. |
Fixes
The sketch below changes the verification to use the secret stored for the user, not the posted one, and adds replay protection. It follows the structure of the real file, and the secret is decrypted with the Encryption lesson's approach. The hidden field is removed.
if (structKeyExists(FORM, "totpCode")) {
// identify the user from the session set after the password check, not from a form field
var email = session.pendingMfaEmail ?: "";
var row = queryExecute(
"SELECT totp_secret, last_used_step FROM usersdata WHERE email = :email",
{ email: { value: email, cfsqltype: "cf_sql_varchar" } },
{ datasource = "localDB" }
);
var secret = decryptSecret(row.totp_secret); // see the Encryption lesson
var matchedStep = validateTOTPStep(secret, FORM.totpCode); // returns the step, or 0
if (matchedStep > 0 AND matchedStep > row.last_used_step) {
queryExecute(
"UPDATE usersdata SET last_used_step = :step WHERE email = :email",
{
step: { value: matchedStep, cfsqltype: "cf_sql_bigint" },
email: { value: email, cfsqltype: "cf_sql_varchar" }
},
{ datasource = "localDB" }
);
sessionRotate();
session.loggedIn = true;
writeOutput("<h3>Authentication successful!</h3>");
} else {
writeOutput("<h3>Invalid TOTP code.</h3>");
}
}This sketch assumes two things the project doesn't have yet: a password check that sets session.pendingMfaEmail before the code step, and an application.totpKey or equivalent set from configuration. decryptSecret and validateTOTPStep are not in the repo, and writing them is the exercise. Also add a column last_used_step (BIGINT, default 0) to usersdata.
Common Beginner Mistakes
Taking the secret from the form at the verification step
Whatever the client sends can be changed. The secret must come from the server's own record for the user.
Hashing the TOTP secret the way a password is hashed
The server has to compute codes from the secret, so it must be recoverable. Encrypt it, with the key stored outside the database.
Accepting the same code twice
A code stays valid for its whole window. Store the last step that was accepted and reject anything at or before it.
Interview Questions
What's the risk when a verification step trusts a secret the client sends back?
The check becomes meaningless. Anyone can supply their own secret and a code computed from it, so the second factor protects nothing.
Why must TOTP secrets be encrypted instead of hashed?
The server computes the expected code from the secret on every check, so the secret has to be readable. A one-way hash would make that impossible.
Summary
You followed the full TOTP flow from the real code: the RFC 6238 computation, enrollment with a QR code, and the verification step. The most important finding is that verification trusts the secret the client sends back, which makes the second factor bypassable. The fix is to read the secret from the server, reject reused codes, and encrypt what's stored.