A contact form is the most common form on the web, and it's a good place to put form handling, validation, email, and output encoding together. The whole project is one file, contact.cfm. Every code block below is that file, built up one part at a time, with the reasoning for each part.
Learning Objectives
After working through this project, you'll be able to:
- Build a form that posts back to the same page and handles both the first load and the submit.
- Validate every field on the server, not only in the browser.
- Use a hidden honeypot field to catch automated spam.
- Send the message with cfmail and report failure without exposing details.
- Echo submitted values back into the form safely.
How the Contact Form Works
Visitor Submits the Form
name, email, subject, message
Validate on the Server
required, email format, length, honeypot
Send With cfmail
to the site owner, reply-to the visitor
Show a Result
success, or the specific field errors
Step 1: Set Up the Variables
cfparam makes sure every form field exists before the page reads it, so a first page load doesn't throw an error. Empty strings are the defaults. The errors struct and the sent flag start empty too.
<cfparam name="form.name" default="">
<cfparam name="form.email" default="">
<cfparam name="form.subject" default="">
<cfparam name="form.message" default="">
<cfparam name="form.website" default="">
<cfset errors = {}>
<cfset sent = false>
<cfset isPost = (cgi.request_method EQ "POST")>Step 2: Validate Every Field on the Server
The browser's required and type="email" attributes help the visitor, but a browser can be bypassed with a single HTTP request. The server has to repeat the checks. Each check stores a message against its field, so the form can show all the problems at once rather than one at a time.
<cfif isPost>
<cfset name = trim(form.name)>
<cfset email = trim(form.email)>
<cfset subject = trim(form.subject)>
<cfset message = trim(form.message)>
<cfif NOT len(name)>
<cfset errors.name = "Please enter your name.">
<cfelseif len(name) GT 100>
<cfset errors.name = "Name must be 100 characters or fewer.">
</cfif>
<cfif NOT len(email)>
<cfset errors.email = "Please enter your email address.">
<cfelseif NOT isValid("email", email)>
<cfset errors.email = "That email address doesn't look valid.">
</cfif>
<cfif NOT len(subject)>
<cfset errors.subject = "Please enter a subject.">
<cfelseif len(subject) GT 150>
<cfset errors.subject = "Subject must be 150 characters or fewer.">
</cfif>
<cfif NOT len(message)>
<cfset errors.message = "Please enter a message.">
<cfelseif len(message) GT 5000>
<cfset errors.message = "Message must be 5,000 characters or fewer.">
</cfif>
</cfif>The length limits matter as much as the required checks. Without them, a single submission can post megabytes of text that gets stored or emailed.
Step 3: The Honeypot Spam Trap
A honeypot is a field a person never sees, because it's hidden by CSS, but simple bots fill in every field they find. The form includes a hidden field named website. A real visitor leaves it empty. If it has any value, the submission is treated as spam and the page quietly reports success without sending anything, so the bot gets no signal to adapt to.
<cfset isSpam = len(trim(form.website)) GT 0>
<cfif isPost AND isSpam>
<!--- a bot filled the trap: report success, send nothing --->
<cfset sent = true>
</cfif>Step 4: Send the Email with cfmail
cfmail needs a to, a from, and a subject. The from address should be an address your mail server is allowed to send as, typically your own domain, because most servers reject mail that claims to come from somewhere else. The visitor's address goes in replyTo, so replying to the message goes to the person who wrote it.
The SMTP server can be set in the ColdFusion Administrator or in the tag. The CF Administrator value is used unless the tag overrides it.
<cfif isPost AND NOT isSpam AND structIsEmpty(errors)>
<cftry>
<cfmail to="owner@yourdomain.com"
from="website@yourdomain.com"
replyTo="#email#"
subject="Contact form: #subject#"
type="text">
Name: #name#
Email: #email#
#message#
</cfmail>
<cfset sent = true>
<cfcatch>
<cflog file="contact" type="error" text="#cfcatch.message# | #cfcatch.detail#">
<cfset errors.general = "We couldn't send your message right now. Please try again later.">
</cfcatch>
</cftry>
</cfif>The catch block logs the real error on the server and shows the visitor a generic message. Showing cfcatch.detail on the page would reveal server and mail configuration details. Replace owner@yourdomain.com and website@yourdomain.com with addresses you control before deploying.
Step 5: Show the Result and Re-Display Input Safely
When validation fails, the form should keep what the visitor typed, so they don't have to retype everything. Those values come from the user, so they're encoded wherever they're printed. Field values go into an attribute, so they use encodeForHTMLAttribute(). Error text in the page body uses encodeForHTML().
<cfif sent>
<div class="alert alert-success">Thanks, your message has been sent.</div>
<cfelse>
<cfif structKeyExists(errors, "general")>
<div class="alert alert-danger">#encodeForHTML(errors.general)#</div>
</cfif>
<form method="post" action="contact.cfm">
<!--- the honeypot: hidden from people, visible to bots --->
<div style="position:absolute; left:-9999px;" aria-hidden="true">
<label>Leave this empty <input type="text" name="website" tabindex="-1" autocomplete="off"></label>
</div>
<label>Name</label>
<input type="text" name="name" value="#encodeForHTMLAttribute(form.name)#">
<cfif structKeyExists(errors, "name")><div class="text-danger">#encodeForHTML(errors.name)#</div></cfif>
<label>Email</label>
<input type="email" name="email" value="#encodeForHTMLAttribute(form.email)#">
<cfif structKeyExists(errors, "email")><div class="text-danger">#encodeForHTML(errors.email)#</div></cfif>
<label>Subject</label>
<input type="text" name="subject" value="#encodeForHTMLAttribute(form.subject)#">
<cfif structKeyExists(errors, "subject")><div class="text-danger">#encodeForHTML(errors.subject)#</div></cfif>
<label>Message</label>
<textarea name="message">#encodeForHTML(form.message)#</textarea>
<cfif structKeyExists(errors, "message")><div class="text-danger">#encodeForHTML(errors.message)#</div></cfif>
<button type="submit">Send</button>
</form>
</cfif>The honeypot is hidden with CSS, not type="hidden". Bots that skip hidden fields would ignore a type="hidden" input, so it has to be visible to the bot and invisible to the person.
A Real Detail: Why the Spam Trap Shows Success
When the honeypot is filled in, the page reports success without sending anything. That's deliberate. If a bot sees an error, it learns the trap exists and can try a different field. If it sees success, it moves on, and nothing reaches the owner's inbox.
Security Review of This Page
| Check | Result | Why |
|---|---|---|
| Input validated on the server | Yes | Each field has a rule, the browser's rules are not relied on |
| Email header injection | Not until the fix below is applied | Name and subject go into mail headers. A line break in either can add extra headers, so strip line breaks before sending (see the fix). |
| Output encoded | Yes | Every value echoed back uses encodeForHTMLAttribute or encodeForHTML |
| Error details hidden from the visitor | Yes | cfcatch.detail goes to the log only |
| CSRF protection | No | A forged request from another site can submit the form. For a contact form the risk is mostly spam, but a real token is the fix (outside this page). |
| Rate limiting | No | Nothing stops one client sending thousands of messages. Add it at the web server or with a counter in the application scope. |
Fix: Strip Line Breaks from Header Fields
A subject or name that contains a line break can add extra headers to the email, such as a Bcc to a stranger. Remove line breaks from any value that goes into a header, before the cfmail call.
<cfset name = reReplace(name, "[\r\n]+", " ", "all")> <cfset subject = reReplace(subject, "[\r\n]+", " ", "all")>
Common Beginner Mistakes
Validating only in the browser
Browser checks can be skipped with a single request. Every rule has to run on the server as well.
Sending the email before the validation has finished
Check that errors is empty before the cfmail call. Sending first and validating afterward means invalid messages still reach the inbox.
Showing cfcatch.detail to the visitor
That's the database, mail server, or path information an attacker wants. Log it and show a generic message.
Hiding the honeypot with type="hidden"
Many bots skip hidden inputs. Hide the field with CSS instead, so bots see it and fill it in.
Interview Questions
Why does a contact form need server-side validation if the browser already checks the fields?
Browser checks are easy to bypass. The server is the only place the rules are guaranteed to run.
What is a honeypot field and how does it work?
A field hidden from people with CSS. Real visitors leave it empty, simple bots fill every field, so a non-empty value identifies a bot.
What's the risk in putting a user-supplied subject straight into a mail header?
A line break in the subject can add new headers to the message, such as an extra recipient. Strip line breaks before the value reaches the header.
Summary
You built a complete contact form in one file: defaults with cfparam, server-side validation with isValid() and length limits, a honeypot to catch bots, a cfmail send with a logged failure path, and output encoding for every echoed value. The security review lists what's still open, CSRF and rate limiting, so you know where the next work is.
What's Next?
The next Practice Project is Employee Management System, which adds records to manage, not just one message to send.