Skip to main content
Ben Nadel at cf.Objective() 2014 (Bloomington, MN) with: Jojo Serquina
Ben Nadel at cf.Objective() 2014 (Bloomington, MN) with: Jojo Serquina ( @jojoserquina )

Using Google reCAPTCHA v3 In ColdFusion

By on
Tags:

Over on my Dig Deep Fitness weight lifting application, I use magic links for passwordless logins. This type of authentication workflow takes an email address and sends a one-time-use link that will automatically log the given user into my ColdFusion application, no password required. A few weeks ago, I started seeing SPAM bots submit this form (for reasons that I can't understand). To combat this malicious attack, I added Google's reCAPTCHA v3 to my login form. This was the first time that I've used reCAPTCHA in a ColdFusion application; so, I thought it might be worth a closer look.

"CAPTCHA" stands for "Completely Automated Public Turing test to tell Computers and Humans Apart". It represents a category of automated tools that help protect online sites from being abused by malicious actors. reCAPTCHA is Google's implementation of such a tool. And, "v3" is the latest implementation of reCAPTCHA at the time of this writing.

reCAPTCHA v3 uses a workflow that includes both client-side and server-side aspects. On the client-side, Google's JavaScript library attempts to evaluate the type of client that is loading the page (ie, is it a human or a bot). This includes making a cross-domain API call back to the Google servers in order to generate a "challenge token". This challenge token is then submitted as a hidden form field along with the HTML form (that is being protected).

On the ColdFusion side, when processing said form, we then have to verify this challenge token against the Google API. The response from the Google API tells us whether or not the challenge token is valid (ie, does it match a token that the client-side script provisioned); and, it gives us a score between 0.0 and 1.0 that tells us how likely the requesting client is to be a human or a bot.

In this blog post, I want to look at both the server-side and the client-side code, starting with the mechanics of verifying the challenge token on the ColdFusion server.

Making external API calls in ColdFusion is always an interesting endeavor because it forces us to think about the separation of concerns. At first blush, making an API call might seem straightforward - just use the CFHttp tag; and, this is ultimately what we will do. But, by drawing boundaries around different concerns within the API consumption, we can create code that is easier to understand and easier to maintain.

When it comes to consuming an HTTP-based service, I like to isolate the mechanics of the HTTP request / response life-cycle from the rest of the code. This allows me to keep the calling code more semantic and less noisy. What I ended up creating is a two component system:

  • ReCaptchaGateway.cfc - the ColdFusion component that handles the low-level HTTP mechanics of the external API call.

  • ReCaptchaClient.cfc - the ColdFusion component that wrangles the inputs, orchestrates the API call, and evaluates the API response.

The ReCaptchaGateway.cfc is a light-weight wrapper around the CFHttp tag. All it does is initiate the external HTTP request and then parse the response:

component
	output = false
	hint = "I provide low-level API methods for validating Google reCAPTCHA challenges."
	{

	/**
	* I initialize the reCAPTCHA gateway with the given parameters. This gateway performs
	* low-level HTTP requests to the remote Google API but does not interpret the response
	* (other than to parse and return the serialized response payload).
	*/
	public void function init(
		required string apiKey,
		numeric timeoutInSeconds = 5
		) {

		variables.apiKey = arguments.apiKey;
		variables.timeoutInSeconds = arguments.timeoutInSeconds;

	}

	// ---
	// PUBLIC METHODS.
	// ---

	/**
	* I verify the given reCAPTCHA token provided by the client-side challenge.
	*/
	public struct function verifyToken(
		required string token,
		required string ipAddress,
		numeric timeoutInSeconds = variables.timeoutInSeconds
		) {

		cfhttp(
			result = "local.apiResponse",
			method = "post",
			url = "https://www.google.com/recaptcha/api/siteverify",
			getAsBinary = "yes",
			timeout = timeoutInSeconds
			) {

			cfhttpparam(
				type = "formfield",
				name = "secret",
				value = apiKey
			);
			cfhttpparam(
				type = "formfield",
				name = "response",
				value = token
			);
			cfhttpparam(
				type = "formfield",
				name = "remoteip",
				value = ipAddress
			);
		}

		var fileContent = getFileContentAsString( apiResponse );

		if ( isFailureResponse( apiResponse ) ) {

			throw(
				type = "ReCaptcha.Gateway.RequestError",
				message = "Google reCaptcha token verification failure.",
				detail = "Returned with status code: #apiResponse.statusCode#",
				extendedInfo = fileContent
			);

		}

		try {

			return( deserializeJson( fileContent ) );

		} catch ( any error ) {

			throw(
				type = "ReCaptcha.Gateway.ParseError",
				message = "Google reCaptcha response could not be parsed.",
				detail = "Returned with status code: #apiResponse.statusCode#",
				extendedInfo = fileContent
			);

		}

	}

	// ---
	// PRIVATE METHODS.
	// ---

	/**
	* I return the given fileContent payload as a UTF-8 encoded string. Even though we are
	* asking the CFHttp tag to return the fileContent as a Binary value, the type is only
	* guaranteed if the request comes back properly. If something goes terribly wrong
	* (such as a "Connection Failure"), the fileContent will still be returned as a simple
	* string. This method will normalize both response cases to a string.
	*/
	private string function getFileContentAsString( required struct apiResponse ) {

		if ( isBinary( apiResponse.fileContent ) ) {

			return( charsetEncode( apiResponse.fileContent, "utf-8" ) );

		}

		return( apiResponse.fileContent );

	}


	/**
	* I determine if the given API response has a failure (ie, non-2xx) status code.
	*/
	private boolean function isFailureResponse( required struct apiResponse ) {

		return( ! apiResponse.statusCode.reFind( "2\d\d" ) );

	}

}

Since this is a self-contained demo, I've included private methods - getFileContentAsString() and isFailureResponse() - for low-level manipulation. But, if this were a more robust application, I'd likely factor these methods out into some sort of "HTTP Utilities" component where they could be shared across any number of API wrappers.

I'm forever fascinated with a Sandi Metz presentation in which she discussed a school of thought whereby any private method should be made into a public method on another object. The idea being that any private methods are likely to represent a "behavior" that can be identified and encapsulated in another object with cleaner boundaries. Sometimes it seems crazy; and, sometimes, it seems brilliant!

The goal of this ColdFusion component - and all of my "gateway" components - is to encapsulate low-level interactions at the "edge" of the system; where control leaves my application and is handed over to another application (or service).

An instance of this ColdFusion component can then be provided to the ReCaptchaClient.cfc component as a "behavior". Meaning, it can be provided as a dependency that implements some abstracted action without the calling context having to understand the implementation details. This allows our ReCaptchaClient.cfc to handle the "business logic" of a reCAPTCHA workflow without having to worry about the underlying HTTP mechanics.

This component exposes one primary method - verifyToken() - which makes the API call to verify the challenge token; and then ensures that the API response contains a score that indicates a "human" actor (and not a bot). It also provides a secondary method - testToken() - which simply throws an error if the verifyToken() call returns false. I find these types of throw-based methods really nice to use within a multi-step workflow.

In the following code, notice that an instance of ReCaptchaGateway.cfc is being provided as a constructor argument:

component
	output = false
	hint = "I provide high-level methods for validating reCAPTCHA challenges."
	{

	/**
	* I initialize the reCAPTCHA client with the given parameters.
	*/
	public void function init( required any reCaptchaGateway ) {

		variables.gateway = arguments.reCaptchaGateway;

	}

	// ---
	// PUBLIC METHODS.
	// ---

	/**
	* I test the given reCAPTCHA token provided by the client-side challenge. If the
	* challenge passes successfully, this method exits quietly. Otherwise, this method
	* throws an error.
	*/
	public void function testToken(
		required string token,
		required numeric scoreThreshold,
		required string ipAddress
		) {

		if ( ! verifyToken( argumentCollection = arguments ) ) {

			throw(
				type = "ReCaptcha.Client.VerifyError",
				message = "Google reCaptcha verification failure.",
				detail = "Challenge was not successful, user might be a bot."
			);

		}

	}


	/**
	* I verify the given reCAPTCHA token provided by the client-side challenge. Returns
	* true if the challenge passed and false otherwise.
	*/
	public boolean function verifyToken(
		required string token,
		required numeric scoreThreshold,
		required string ipAddress
		) {

		// If no token has been provided by reCAPTCHA's client-side interception, then we
		// know that the user is attempting to bypass our security protocols. There's no
		// need to make the API call to verify.
		if ( ! token.len() ) {

			return( false );

		}

		var apiResponse = gateway.verifyToken( token, ipAddress );

		// The "success" flag merely indicates that the provided challenge token was valid
		// and matches an unused token that Google has on its side. This, alone, does not
		// mean that the requesting client is a human.
		if ( ! apiResponse.success ) {

			return( false );

		}

		// The reCAPTCHA score ranges from 0.0 to 1.0 (11 inclusive readings). This
		// determines how likely the requesting client is to be a human. 0.0 means very
		// likely to be a BOT. 1.0 means very likely to be a HUMAN.
		return( apiResponse.score >= scoreThreshold );

	}

}

As you can see, when we call verifyToken() or testToken(), we must pass in a scoreThreshold. Google's reCAPTCHA system doesn't tell us if a given client is a human or a bot — it tells us how likely it is that a given client is a human or a bot. It is then up to us, as product engineers, to decide how strict we need to be in the given HTML form.

Unfortunately, the reCAPTCHA documentation on this matter isn't super prescriptive. It basically says, when there's a lower interaction score, you should be more restrictive in your application (and possibly add further authentication steps in your given workflow). It also says you might want to observe the reCAPTCHA scores in production for a while before actually verifying them on the server-side:

As reCAPTCHA v3 doesn't ever interrupt the user flow, you can first run reCAPTCHA without taking action and then decide on thresholds by looking at your traffic in the admin console. By default, you can use a threshold of 0.5.

This last line - "By default, you can use a threshold of 0.5" - isn't even very clear. I assume what it means is that using a threshold of 0.5 is a good place to start when differentiating between bots and humans. If that's the case, it might make sense to set 0.5 as a default for scoreThreshold. But, for the moment, I'd rather push that responsibility up the stack to the calling context (leaving it as a required parameter).

Now that we have our reCAPTCHA ColdFusion components ready to go, we need to integrate the reCAPTCHA client-side code into our login form. This can be done imperatively with JavaScript; or, it can be done declaratively with data- attributes (and a callback). For the sake of simplicity, I'm using the declarative approach.

The declarative approach works by decorating the submit button with data- attributes and a CSS class that indicate which buttons to observe and which function to call once the challenge token has been provisioned. Once the challenge token has been provisioned (and automatically injected into the HTML form), we can use the callback to explicitly proceed with the form processing.

In the following ColdFusion code, I've put together a one-page workflow that contains the form processing logic at the top and the form HTML at the bottom. For the sake of simplicity, I'm instantiating the ReCaptchaClient.cfc on every page request (instead of caching it in a persistent scope):

<cfscript>

	// SECURITY CAUTION: You should not show the SECRET KEY to anyone. This is a set of
	// throw-away keys that I have created specifically for this demo.
	config = {
		siteKey: "6LdiDTUpAAAAAJ62pHfEm8Cn6tD4a84XWxBuejDf",
		secretKey: "6LdiDTUpAAAAAK7KDXfQ6aBCHpEATcZHikm-XUzi"
	};

	// Using Inversion of Control (IoC) to create the gateway component and then provide
	// it to the reCAPTCHA client component as a dependency.
	// --
	// NOTE: Normally, I would cache this ColdFusion component in a persistent scope; but,
	// for the sake of the demo and simplicity, I am re-creating it on every request.
	reCaptchaClient = new ReCaptchaClient(
		new ReCaptchaGateway( config.secretKey )
	);

	// ------------------------------------------------------------------------------- //
	// ------------------------------------------------------------------------------- //

	param name="form.submitted" type="boolean" default=false;
	param name="form.username" type="string" default="";
	param name="form.password" type="string" default="";
	// This field is automatically populated by the client-site reCAPTCHA challenge.
	// Google's script will intercept the form submission process, evaluate the client
	// requesting the submission, populate this form field, and then proceed with the
	// normal form submission (via the provided callback).
	param name="form[ 'g-recaptcha-response' ]" type="string" default="";

	errorMessage = "";

	if ( form.submitted ) {

		try {

			// NOTE: Each reCAPTCHA token is only valid for 2-minutes and can only be
			// verified once in order to prevent replay attacks.
			reCaptchaClient.testToken(
				token = form[ "g-recaptcha-response" ],
				scoreThreshold = 0.5,
				ipAddress = cgi.remote_addr
			);

			// TODO: Actual authentication logic for the application ...

			location(
				url = "./home.cfm",
				addToken = false
			);

		} catch ( ReCaptcha.Client.VerifyError error ) {

			errorMessage = "Your login form has expired. Please try submitting your form again.";

		} catch ( any error ) {

			errorMessage = "An unexpected error occurred."

		}

	} // END: Submitted.

</cfscript>
<cfoutput>

	<!doctype html>
	<html lang="en">
	<head>
		<meta charset="utf-8" />
		<meta name="viewport" content="width=device-width, initial-scale=1" />		
	</head>
	<body>

		<h1>
			Google reCAPTCHA V3 Demo In ColdFusion
		</h1>

		<cfif errorMessage.len()>
			<p>
				<strong>Error:</strong> #encodeForHtml( errorMessage )#
			</p>
		</cfif>

		<form id="login-form" method="post">
			<input type="hidden" name="submitted" value="true" />

			<p>
				<strong>Username:</strong><br />
				<input type="text" name="username" size="30" />
			</p>
			<p>
				<strong>Password:</strong><br />
				<input type="password" name="password" size="30" />
			</p>
			<p>
				<!--
					These "data-" attributes tell reCAPTCHA that it needs to intercept the
					form submission and inject a challenge token. The data-callback
					function will be invoked with the challenge token once it is obtained.
				 -->
				<button
					type="submit"
					data-sitekey="#encodeForHtmlAttribute( config.siteKey )#"
					data-action="submit"
					data-callback="handleToken"
					class="g-recaptcha">
					I'm totally a Human, Login!
				</button>
			</p>
		</form>

		<!---
			Load reCAPTCHA script and provide submission callback.
			// --
			FROM THE DOCUMENTATION: Generally speaking, the more context that reCAPTCHA
			has about a page, the better informed it is to determine whether user actions
			are legitimate. This is particularly true when using versions of reCAPTCHA
			that don't rely on user challenges. Thus, waiting to load reCAPTCHA until a
			specific restricted action occurs (for example, form submission) is generally
			not recommended.
		--->
		<script src="https://www.google.com/recaptcha/api.js"></script>
		<script type="text/javascript">

			// Once Google generates a challenge token and injects it into the form as a
			// hidden input ("g-recaptcha-response"), it will invoke this callback. We
			// must then continue on with the submit workflow.
			function handleToken( token ) {

				document.getElementById( "login-form" )
					.requestSubmit()
				;

			}

		</script>

	</body>
	</html>

</cfoutput>

As you can see, we don't really do much in this CFML. The form submission is automatically intercepted and the challenge token is automatically injected into the HTML form. The only thing that we have to do is verify the challenge token at the top of the form processing in order to ensure that the requesting user is a real human.

Now, when we render our ColdFusion login form, there are two things to notice. First, we see a reCAPTCHA badge show-up (unobtrusively) on the bottom-right edge of the page:

Login form showing reCAPTCHA badge at bottom-right edge of screen.

And, when we submit the form, we can see the reCAPTCHA challenge token automatically showing up in the HTML form data:

Chrome dev tools network activity showing form submission data complete with 'g-recaptcha-response' field.

Once this request lands on the ColdFusion server, we then pass it into the testToken() method; which, behind the scenes, is making an API call back to Google with the challenge token. With some added logging, I can see that the API response is this:

{
	"success": true,
	"score": 0.9,
	"action": "submit",
	"hostname": "localhost",
	"challenge_ts": "2023-12-19T12:13:05Z"
}

As you can see, Google reCAPTCHA is only 0.9 confident that I'm a human. But, hey, that's good enough for this login form since we're using 0.5 as the score threshold.

In the end, I'm excited to say that by adding reCAPTCHA v3 to my ColdFusion fitness application, I've completely eliminated the previous bot-based SPAM attacks! I still see the malicious traffic coming through; but, none of the requests make it past the challenge token verification step.

Want to use code from this post? Check out the license.

Reader Comments

15,688 Comments

Completely coincidentally, the NPR podcast, "How I Built This", just re-aired an epside from 2020 which is an interview with Luis von Ahn , the guy who invented CAPTCHA (and who founded Doulingo). It's a really interesting interview that talks about the origin of CAPTCHA and how it was eventually transformed into the technology that digitized the world's books.

Post A Comment — I'd Love To Hear From You!

Post a Comment

I believe in love. I believe in compassion. I believe in human rights. I believe that we can afford to give more of these gifts to the world around us because it costs us nothing to be decent and kind and understanding. And, I want you to know that when you land on this site, you are accepted for who you are, no matter how you identify, what truths you live, or whatever kind of goofy shit makes you feel alive! Rock on with your bad self!
Ben Nadel