Skip to main content
Ben Nadel at the jQuery Conference 2010 (Boston, MA) with: Rachel Makrucki
Ben Nadel at the jQuery Conference 2010 (Boston, MA) with: Rachel Makrucki

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 }
];
}
}
view raw Application.cfc hosted with ❤ by GitHub

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>
view raw send-email.cfm hosted with ❤ by GitHub

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>
view raw unsubscribe.cfm hosted with ❤ by GitHub

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!

Markdown formatting: Basic formatting is supported: bold, italic, blockquotes, lists, fenced code-blocks. Read more about markdown syntax »
Comment Etiquette: Please do not post spam. Please keep the comments on-topic. Please do not post unrelated questions or large chunks of code. And, above all, please be nice to each other - we're trying to have a good conversation here.
Cancel
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