Skip to main content
Ben Nadel at cf.Objective() 2013 (Bloomington, MN) with: Heather Harkins
Ben Nadel at cf.Objective() 2013 (Bloomington, MN) with: Heather Harkins

You Can Now Edit Your Comments On This Blog Using SHA1PRNG Secure Tokens

By
Published in Comments (5)

This has been a long-desired and much-asked-for feature that I just never put in the time to create. But, in the last few weeks, I've been modernizing my blogging platform on Adobe ColdFusion 2021. And finally, you can now edit your comments in the first few hours after posting them. Since this blog has neither session management nor user management, I decided to use cryptographically secure token generation in ColdFusion in order to enforce security around the editing capabilities.

If you had to log into this blog in order to post a comment, editing comments would be a no-brainer. However, there is no authentication wall. The only reason it looks like I know who you are is because I save a few "author" cookies after you post a comment. As such, I can't really lock editing functionality down to a given user or session.

Instead, what I am doing is generating a random, temporary, cryptographically secure token that is associated with a given comment. This token is then stored in the database and persisted on the user's end as an expiring cookie.

On the back-end, I have a MySQL database table that looks like this:

CREATE TABLE `blog_comment_edit_token` (
	`id` int(10) unsigned NOT NULL AUTO_INCREMENT,
	`entryID` int(10) unsigned NOT NULL,
	`commentID` int(10) unsigned NOT NULL,
	`createdAt` datetime NOT NULL,
	`expiresAt` datetime NOT NULL,
	`value` varchar(500) NOT NULL,
	PRIMARY KEY (`id`),
	KEY `IX_byExpired` (`expiresAt`),
	KEY `IX_byComment` (`commentID`)
) ENGINE=InnoDB;

The cryptographically secure token is stored in the value column. Note that I have an index defined on the expiresAt column - this is used to expunge expired tokens without having to do a full-table scan (though, since these records are all time-based, this table should be relatively small all of the time).

Now, when a person leaves a comment, I generate a secure token using the SHA1PRNG algorithm in rangRange():

<cfscript>

	/**
	* I generate a cryptographically secure random token with the given length.
	*/
	private string function generateTokenValue( numeric tokenLength = 200 ) {

		var letters = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
		var letterCount = letters.len();
		var parts = [];

		for ( var i = 1 ; i <= tokenLength ; i++ ) {

			parts.append( letters[ randRange( 1, letterCount, "SHA1PRNG" ) ] );

		}

		return( parts.toList( "" ) );

	}

</cfscript>

This token is then stored in the database and sent back to the user as a Set-Cookie header using a name that is based on the comment ID:

<cfscript>

	cfcookie(
		name = "BLOG_COMMENT_EDIT_TOKEN_#commentID#",
		value = secureToken,
		expires = secureTokenExpiresAt,
		httpOnly = true
	);

</cfscript>

When I then render the list of comments for a particular blog post, I just use a cookie.keyExists() check on each iteration to see if I should render an "Edit this comment" button:

<cfloop item="comment" array="#comments#">

	<!--- Truncated for demo. --->

	<cfif cookie.keyExists( "BLOG_COMMENT_EDIT_TOKEN_#comment.id#" )>

		<a href="...">Edit this comment &rarr;</a>

	</cfif>

</cfloop>

This shows up in the user interface (UI) like this:

An edit this comment button on the blog.

If the user then goes to edit the given comment, I check to see if the appropriate cookie exists; and, if it does, I check to see if the secure token (cookie value) matches and that it is not yet expired:

<cfscript>

	try {

		secureToken = ( cookie[ "BLOG_COMMENT_EDIT_TOKEN_#comment.id#" ] ?: "" );

		// NOTE: All access methods on the tokens will expunge expired tokens
		// from the database before they even check for the given token.
		editToken = commentEditTokenService.getTokenByValue(
			commentID = comment.id,
			value = secureToken
		);

	} catch ( any error ) {

		// If the token is expired, doesn't exist, or doesn't match the given
		// comment ID, it will throw an error. Redirect the user back to the
		// blog post detail page.
		location( url = entryUrl, addToken = "false" );

	}

</cfscript>

And that's all there is to it. It's not a perfect solution. But, given that I have no authentication wall or user management on the blog, it gets the job done while still providing security. The secure token is sufficiently long and random so as to be unguessable. And, any attempt to brute force it would likely melt my hardware long before a valid match was found. And, of course, since the token is only valid for a brief period of time after the comment is posted, such brute forcing is constrained by both time and character space.

I could have made the secure token even longer; but, I have to take precautions since it's being stored as a cookie. There are size limits on cookie HTTP headers. As such, I had to balance between security and the possibility that an active reader leaving a few comments in a row wouldn't exceed their HTTP header size limit. Since this blog doesn't get that much user interactivity, I don't believe that will happen; but, it's something I had to keep in mind.

All in all, it's just been a lot of fun actually putting time into making my blog experience more modern (for me) and more enjoyable for my readers! Long live ColdFusion!

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

Reader Comments

15,848 Comments

So, shortly after I added the ability to edit comments, I had to add comment moderation. Unfortunately, the approach that I have for editing doesn't play nicely with moderation since the comment isn't created at the time of posting (and hence, the cookies don't get created for editing). I'm going to have a re-think on this to see if I can figure out how to making the moderation feature work well with editing such that you can edit your comment after it has been moderated.

15,848 Comments

I've had a couple of false-starts with the post-moderation alterations to the editing feature. But, I always run into a problem when thinking about how to associate the token back to the comment and the requesting-user after the comment has been moderated. Technically, it's not a problem, if I were doing the editing in isolation; but, when thinking about how to do it in a way that I can keep all the view-data cached is challenging with the current approach. What I want to avoid is having to run special user-based queries on every request.

I am thinking that the easiest way is going to be to actually associate a token with the comment itself. But, I'm still thinking through the details. And, how to change the cookies to work with this. Still noodling on it....

15,848 Comments

Ok, I think I finally got this working 💪 I had to add a pendingCommentID column to the edit-token table. Now, I'm always creating an edit token, regardless of comment moderation. And, when I approve the comment, the edit token is "upgraded" to be for the approved comment.

Here's the current edit token table:

CREATE TABLE `blog_comment_edit_token` (
	`id` int(10) unsigned NOT NULL AUTO_INCREMENT,
	`entryID` int(10) unsigned NOT NULL,
	`pendingCommentID` int(10) unsigned DEFAULT NULL,
	`commentID` int(10) unsigned DEFAULT NULL,
	`createdAt` datetime NOT NULL,
	`expiresAt` datetime NOT NULL,
	`value` varchar(500) NOT NULL,
	PRIMARY KEY (`id`),
	UNIQUE KEY `IX_byPendingComment` (`pendingCommentID`),
	UNIQUE KEY `IX_byComment` (`commentID`),
	KEY `IX_byExpired` (`expiresAt`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

Two points to note: Both the pendingCommentID and the commentID are NULL'able. While I normally hate the idea of a nullable column, I had to do it in this case so that I could change the KEY on those columns to be UNIQUE KEY. This allows me to use the database to make sure I don't accidentally create dirty data.

If a new comment needs approval, the new record is inserted with:

  • pendingCommentID = :pendingID
  • commentID = NULL

Then, once the comment is approved, these columns get "upgraded":

  • pendingCommentID = NULL
  • commentID = :commentID

The other major difference is that the cookie is no longer named based on the comment ID - instead, it's named based on the token ID. This way, I don't have to worry that the pending-ID and the final comment-ID change through the approval life-cycle.

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