DevLearningTools

MODULE 18 · LESSON 04

Two-Factor Login (TOTP)

A complete TOTP two-factor login in ColdFusion, walked through file by file from its real source: QR enrollment with ZXing, the RFC 6238 code check, and a security review that finds a bypass where the server trusts the secret the client sends back.

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 / FolderRole
generateSecret.cfmEnrollment and verification: asks for an email, then either shows a QR or asks for a code
validateTotp.cfmThe TOTP functions: random secret, code computation, and validation
lib/zxing-2.1-core.jar, zxing-2.1-javase.jarQR code generation
README.mdSetup 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.

ServerServer-wide lib folder (from the README)Project-local option
Adobe ColdFusionC:\ColdFusion2025\cfusion\libProject lib/ folder under the webroot, as the README shows
LuceeC:\lucee\tomcat\libProject lib/ folder under the webroot, as the README shows
NOTE

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.

validateTotp.cfm
<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>
NOTE

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.

generateSecret.cfm
<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.

generateSecret.cfm (the two lines that cause it)
<input type="hidden" name="secret" value="#result.totp_secret[1]#">
generateSecret.cfm (verification uses the posted secret)
variables.valid = validateTOTP(FORM.secret, FORM.totpCode);
NOTE

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

FindingWhereWhy it matters
Secret stored in plaintextusersdata.totp_secret, the INSERTAnyone 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 protectionvalidateTOTPA 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 usergenerateSecret.cfmNo email or session is checked at the code step, so a success doesn't say whose account was verified.
Exception message shown to the usergenerateSecret.cfm catch blockwriteOutput("Error: " & e.message) can reveal database details.
Success creates no sessiongenerateSecret.cfmThe page prints a message. Nothing marks a login as complete, so this isn't yet a second factor for real logins.
No authentication on enrollmentgenerateSecret.cfmAnyone 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.

generateSecret.cfm (verification, fixed sketch)
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>");
    }
}
NOTE

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.