Skip to main content
Ben Nadel at CFCamp 2023 (Freising, Germany) with: Markus Wollny
Ben Nadel at CFCamp 2023 (Freising, Germany) with: Markus Wollny

Powering Email Unsubscribe Links Using Signed URLs In ColdFusion

By
Published in

Earlier this week, I talked about using signatures to prevent URL tampering in ColdFusion. Then, yesterday, I was having a conversation about spam emails and unsubscribe links. It occurred to me that using signed URLs is one way in which unsubscribe links can be implemented in ColdFusion. As such, I wanted to run through a small demo.

An unsubscribe link in the footer of a marketing email has a few different characteristics:

  • It needs to be user-specific. Meaning, each email gets its own unsubscribe link so that one user doesn't accidentally unsubscribe another user.

  • It needs to provide non-credentialed access. Meaning, we don't want the user to have to log into the app in order to process the unsubscribe operation. We're aiming to create a maximally convenient workflow for a frustrated user.

  • It (probably) shouldn't be time-boxed. Meaning, a user can go into a marketing email that was sent 6 months ago and use the unsubscribe link. Again, we're trying to maximize the convenience here for the user.

  • The same unsubscribe link can be used multiple times. This is especially true if the landing page provides both unsubscribe and re-subscribe functionality.

Ultimately, an unsubscribe link has a very limited set of security hooks. But, we still need to make it secure. And, that's where the HMAC (Hashed Message Authentication Code) signature comes into play. For this demo, the only data-point that we're including in the URL is the target userID. And, to make sure that the userID hasn't been tampered with, we're including an HMAC signature.

To set this demo up, I'm going to define an Application.cfc ColdFusion application framework component that caches our URL signing service and some mock user data. The UrlSigner.cfc that I instantiate in my onApplicationStart() event handler is the same one that I use in my previous post. As such, I won't reproduce the code here other than to say that the default hashing algorithm is HmacSha256 and the default encoding is base64url.

component
	output = false
	hint = "I provide the application settings and event handlers."
	{

	this.name = "UnsubscribeDemo";
	this.applicationTimeout = createTimeSpan( 1, 0, 0, 0 );
	this.sessionManagement = false;
	this.setClientCookies = false;

	// Mailhog test server.
	this.mailServers = [{
		host: "127.0.0.1",
		port: 1025
	}];

	// ---
	// LIFE-CYCLE METHODS.
	// ---

	/**
	* I initialize the ColdFusion application.
	*/
	public void function onApplicationStart() {

		// This ColdFusion component helps generate message authentication codes (HMAC).
		application.urlSigner = new UrlSigner(
			secretKey = binaryDecode( fileRead( "./secret.key" ), "base64" )
		);

		// Uses an ORDERED STRUCT to hold fake user data so that we can look users up by
		// ID as well as iterate over them in order.
		application.users = [
			"1": { id: 1, name: "Molly",  email: "molly@example.com",  subscribed: true },
			"2": { id: 2, name: "Arnold", email: "arnold@example.com", subscribed: true },
			"3": { id: 3, name: "Kim",    email: "kim@example.com",    subscribed: true }
		];

	}

}

Notice that each of our mock users has a subscribed property. This property determines whether or not the user receives marketing emails. And, our unsubscribe link is going to take the user to a page that allows each user to manage this property without logging in.

Let's look at our send-email.cfm template, which loops over the mock users, generates a secure link for each user, and then sends out an email. To test the emails, I'm using the very handy Mailhog SMTP development server.

Notice in the following template that we're skipping over any user whose user.subscribed property is false:

<cfscript>

	// DEMO ONLY: For making it easier to read the inbox subject-lines.
	sendID = createUniqueId( "counter" );

	// Send an email to each of the users.
	loop
		key = "userID"
		value = "user"
		struct = application.users
		{

		// If the user is not subscribed to emails, continue onto next user.
		if ( ! user.subscribed ) {

			continue;

		}

		// We're about to embed a user-specific link within an email that allows the
		// user to change their state without having to authenticate with credentials.
		// As such, we need to make sure that the URL cannot be tampered with; otherwise,
		// we'd expose a way in which a malicious actor could change the state of any
		// user.
		signature = application.urlSigner.generateSignature({
			userID: user.id,
			feature: "unsubscribe-#user.id#"
		});

		// Prepare data structure to be consumed in email template.
		partial = {
			user: user,
			unsubscribeUrl: (
				"http://#cgi.http_host#" &
				"/signed-url/unsubscribe.cfm" &
				"?userID=#encodeForUrl( user.id )#" &
				"&signature=#encodeForUrl( signature )#" // Include signature!
			)
		};

		mail
			to = partial.user.email
			from = "no-reply@example.com"
			subject = "Great new offers! (#sendID#)"
			type = "html"
			async = false
			{

			include "./email-content.cfm";
		}

	}

</cfscript>

<a href="./send-email.cfm">Send again!</a>

In my .generateSignature() call, I'm passing in the user ID, which we're including in the unsubscribe link. But, I'm also including another key, feature, that we're not passing through in the link. This secondary feature property isn't strictly necessary (from what I've read); but, I'm including it as a kind of "pepper" that differentiates one type of signed URL for another.

Caution: This is probably a good place to warn you that I'm not a security expert.

Once we have the signed URL prepared, I'm sending the following email - I always like to keep my email templates separate from the CFMail tag:

<cfoutput>
	<h1>
		Hello #encodeForHtml( partial.user.name )#
	</h1>
	<p>
		Check out all our new offers!
	</p>
	<p>
		<a href="#partial.unsubscribeUrl#">Unsubscribe</a>
	</p>
</cfoutput>

When the user clicks the "Unsubscribe" link, we need to take them to a page that validates the URL signature (ensuring that the userID wasn't tampered with); and, allows them to both unsubscribe and re-subscribe to marketing emails.

Since this page has two functions, I'm not performing any action on initial load. Instead, I'm going to render an HTML form that tells the user whether or not they're currently subscribed to marketing emails; and, allows them to toggle the current state. To keep this simple during form processing, I'm just updating the in-memory data structures.

<cfscript>

	// Ensure request parameters.
	param name="url.userID" type="numeric";
	param name="url.signature" type="string";
	param name="form.action" type="string" default="";

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

	// Ideally, we want to test the URL signature before we do anything else so that in
	// addition to protecting the user we can also protect other upstream services (such
	// as the database). If the signature doesn't match, an error is thrown.
	application.urlSigner.testSignature(
		{
			userID: url.userID,
			feature: "unsubscribe-#url.userID#"
		},
		url.signature
	);

	// ASSERTION: At this point, we know that the URL was not tampered with (regarding the
	// user ID). As such, we can trust that the current user (at least) has access to the
	// target user's email account. To that end, we can render this page as if the user
	// has been properly authenticated (noting that this trust does NOT extend to any
	// other page within the current application).

	// Get the user record (for demo simplicity, I'm not validating this).
	user = application.users[ url.userID ];

	// Process form submission.
	switch ( form.action ) {
		case "subscribe":
			user.subscribed = true;
		break;
		case "unsubscribe":
			user.subscribed = false;
		break;
	}

</cfscript>
<cfoutput>

	<!-- To get name to show in the browser tab. -->
	<title>
		#encodeForHtml( user.name )#
	</title>

	<h1>
		Hello #encodeForHtml( user.name )#
	</h1>

	<form
		method="post"
		action="#cgi.script_name#?userID=#encodeForUrl( user.id )#&signature=#encodeForUrl( url.signature )#">

		<cfif user.subscribed>

			<p>
				You are currently <strong>subscribed</strong> to all emails.
			</p>
			<p>
				<button type="submit" name="action" value="unsubscribe">
					Unsubscribe Now!
				</button>
			</p>

		<cfelse>

			<p>
				You are currently <em>unsubscribed</em> to all emails.
			</p>
			<p>
				<button type="submit" name="action" value="subscribe">
					Subscribe Again
				</button>
			</p>

		</cfif>

	</form>

</cfoutput>

As you can see, the very first thing we do at the top of the page is test the incoming HMAC signature to make sure that the url.userID value hasn't been tampered with. And, once we know that the request is "authorized", we can then go about fetching the user data and either rendering or processing the form.

Whenever the form is submitted, we have to include the original URL parameters (userID and signature) in the form action so that the subsequent page load continues to pass HMAC signature verification. This also allows the form to be submitted multiple times. Which, in turn, allows the user to continue managing their subscription state over time:

A lot of what I understand about the security of this workflow comes from past conversations and from reading Wikipedia and StackOverflow. I really would love to sit down with someone and hammer-out a deeper understanding of the finer-points of hardening an access-point within a ColdFusion application. But, for now, I hope this can at least point you in the right direction.

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

Reader Comments

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