
How I Secured a Static Website Contact Form
Table of Contents
Introduction
Building a Secure Contact Form for a Static Website
Recently, we have had several opportunities to build static websites for our clients.
At AEDI, we primarily create websites using WordPress, but depending on our clients’ requirements and the purpose of the website, we also build static websites using HTML, CSS, and JavaScript.
The website we built this time is also a static website using HTML, CSS, and JavaScript for the main website itself. However, because server-side processing is required to implement a contact form, we also use PHP for the form.
With WordPress, contact forms can be installed relatively easily using form plugins such as Contact Form 7 or WordPress’s built-in Form block. With a static website, however, we need to design and implement the form processing, input validation, CSRF protection, session management, email delivery, and other components ourselves.
In this article, using an actual contact form we built for a static website as an example, I would like to explain, as clearly and in as much detail as possible, what security measures we implemented.
What Are Static and Dynamic Websites?
A static website is a website that delivers pre-created HTML files and other resources directly from a web server. A dynamic website, on the other hand, is a website in which server-side programs process requests according to user access or input and generate the content to be displayed. WordPress is generally classified as a dynamic website because it uses PHP and a database to generate pages.
About This Article
I am by no means a security expert. I do not constantly follow the latest security information either, and I would say that security is not exactly my strongest area.
For this particular form, I researched the information I needed and, with the help of AI, designed and built the implementation myself. Therefore, I do not claim that this implementation is 100% secure.
In the first place, there is no such thing as 100% perfect security. New vulnerabilities can be discovered, and attack techniques continue to evolve.
This article is not intended to be a complete security specification that says, “Implement these measures and your website will be secure.” Rather, please consider it a technical record of how we approached the security of a contact form for a static website and what measures we implemented during an actual website development project.
Architecture of the Contact Form
The contact form we built this time uses HTML, CSS, JavaScript, and PHP.
contact.html
↓
confirm.php
↓
send.php
↓
Email delivery
↓
thanks.html
In contact.html, users enter their information into the form.
In confirm.php, we check the CSRF token, Honeypot, input values, and other data. After validating the submitted data, we store it in the session if everything is valid.
In send.php, we retrieve the data from the session, perform final validation, and then send both the notification email to the administrator and the automatic reply email to the user. Once the email-sending process is complete, the user is redirected to thanks.html.
Rather than simply “sending the information entered into the form by email,” we have designed the form so that multiple security checks are performed between the confirmation page and the final submission.
Security Measures Implemented
The main security measures implemented in this form are as follows:
- CSRF protection
- Protection against session fixation attacks
- Session cookie protection
- XSS protection
- Server-side validation
- Email header injection protection
- Security headers
- Protection against duplicate submissions
- Spam protection using a Honeypot
Let’s take a look at each of them.
CSRF Protection
What Is CSRF?
CSRF stands for Cross-Site Request Forgery.
It is an attack in which a malicious website causes a user’s browser to send a request to a trusted website without the user’s intention.
For example, suppose a user is logged in to a website and then visits a malicious website. That malicious website may cause the user’s browser to send an unintended request, such as a form submission, to another website.
For contact forms, it is necessary to have a mechanism to verify that a request actually originated from the legitimate form in order to prevent unintended data from being submitted by a third party.
This is where a “CSRF token” is used.
What Is a CSRF Token?
A CSRF token is an unpredictable random value used to verify that a request was submitted from a legitimate form.
When the form is displayed, the server generates a random token and stores it in the session.
At the same time, the token is embedded into the form.
When the user submits the form, the server compares:
- the CSRF token stored in the session
- the CSRF token submitted by the form
If the two values match, the server considers the request likely to have originated from the legitimate form and continues processing.
If the token is missing or the two values do not match, the request is treated as invalid and processing is stopped.
In this way, the purpose of a CSRF token is to make it difficult for an attacker to create a valid request without knowing the correct token.
Generating the CSRF Token
In this form, we use PHP’s random_bytes() to generate a random value and store it in the session as the CSRF token.
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
random_bytes() is used to generate a cryptographically secure sequence of random bytes.
We then convert the generated value into a hexadecimal string using bin2hex(), allowing us to store the CSRF token as a convenient string.
Validating the Submitted Token
We compare the CSRF token submitted by the form with the token stored in the session.
In this implementation, rather than using a simple == or === comparison, we use hash_equals().
if ( empty($session_token) || empty($post_token) || !hash_equals($session_token, (string)$post_token) ) { http_response_code(403); exit('Invalid request.'); }First, we check that both the session-side token and the POST-side token exist.
We then compare the two values using hash_equals().
If the token does not exist or the values do not match, the server returns HTTP status code 403 (Forbidden) and stops processing.
Checking the Token Again During Final Submission
In this form, passing the CSRF check once on the confirmation page does not mean that the request is automatically accepted for final submission.
The CSRF token is also checked again in send.php, where the final email-sending process takes place.
We designed the implementation so that validation is performed again at the point where the final operation takes place, taking into account the possibility that data may have been modified or that an invalid request may have been submitted during the form process.
Destroying the CSRF Token After Submission
After the email has been sent successfully, the CSRF token used for the submission is removed from the session.
unset($_SESSION['csrf_token']);
This makes it more difficult for the same CSRF token to be reused after the submission has been completed.
Our CSRF Protection Flow
Our form implements CSRF protection through the following process:
Generate CSRF token
↓
Store it in the session
↓
Embed the token into the form
↓
Submit the form
↓
Compare the session token with the POST token
↓
Validate the token again in send.php
↓
Send email
↓
Destroy the CSRF token
Rather than simply creating a CSRF token, we have implemented the entire process of generating, storing, validating, and destroying the token as one continuous security measure.
Protection Against Session Fixation Attacks
We use PHP sessions to maintain the state between the confirmation page and the final submission.
Because input data and the CSRF token are stored in the session, it is important to manage the session ID appropriately.
What Is Session Fixation?
Session fixation is an attack in which an attacker causes a user to use a session ID that the attacker already knows, and then attempts to use that session to gain unauthorized access to the user’s session.
For this reason, it is important not to allow session IDs to be arbitrarily specified from outside, and to regenerate the session ID before or after important operations when appropriate.
Using Cookies Only for the Session ID
In this form, we configure PHP to use cookies only for the session ID.
ini_set('session.use_only_cookies', '1');Depending on the configuration, PHP can pass session IDs through URLs and other mechanisms.
By enabling session.use_only_cookies, we ensure that the session ID is handled using cookies only.
This allows us to avoid methods that include the session ID in URLs.
Making It More Difficult to Accept Invalid Session IDs
We also enable session.use_strict_mode.
ini_set('session.use_strict_mode', '1');When this setting is enabled, PHP is less likely to accept a session ID that has not yet been created by the server.
This is an important setting for protecting against session fixation attacks in which an attacker attempts to make a user use a session ID prepared by the attacker.
Regenerating the Session ID
We also use:
session_regenerate_id(true);
when appropriate at the beginning of the form process to regenerate the session ID.
session_regenerate_id() is a PHP function that issues a new session ID while preserving the current session data.
Regenerating the session ID helps avoid continuing to use a previously issued session ID and improves resistance to session fixation attacks.
When true is specified, the session data associated with the old session ID is also deleted.
Session ID Guessing and Theft
The risks that need to be considered when managing sessions are not limited to session fixation attacks.
If a session ID is guessed or stolen through some other method, an attacker may be able to use that session.
For this reason, our form does not rely solely on session.use_only_cookies, session.use_strict_mode, and session_regenerate_id(). As explained in the next section, we also use cookie attributes such as Secure, HttpOnly, and SameSite to protect the session from multiple angles.
Protecting the Session Cookie
In this form, we configure several security attributes for the cookie that stores the session ID.
session_set_cookie_params([ 'lifetime' => 0, 'path' => '/', 'secure' => $is_https, 'httponly' => true, 'samesite' => 'Lax', ]);
Cookies may contain important information such as session IDs.
Therefore, it is important not only to manage the session ID itself properly, but also to configure the conditions under which the cookie containing the session ID is transmitted.
Secure
When the Secure attribute is set, the cookie is sent only over HTTPS connections.
In our implementation, we use:
'secure' => $is_https,
so that the Secure attribute is enabled when the connection uses HTTPS.
This helps prevent the session cookie from being transmitted over an HTTP connection and avoids including the cookie in plaintext HTTP traffic.
HttpOnly
When the HttpOnly attribute is set, JavaScript cannot directly access the cookie.
Our configuration uses:
'httponly' => true,
This reduces the risk of the session cookie being accessed through JavaScript.
However, HttpOnly does not prevent XSS itself.
It is simply a measure that restricts JavaScript access to the cookie.
SameSite
The SameSite attribute controls the conditions under which cookies are sent with requests originating from another site.
For this form, we use:
'samesite' => 'Lax',
to set SameSite=Lax.
With SameSite=Lax, cookies are not sent unconditionally with cross-site requests.
This provides one layer of defense against CSRF attacks in which a malicious website attempts to use a user’s browser to send a request.
SameSite Alone Does Not Complete CSRF Protection
It is important to note that configuring SameSite does not mean that CSRF protection is complete.
Our form uses CSRF token validation, as explained in the previous section, in addition to the restrictions on cookie transmission provided by SameSite=Lax.
A CSRF token is a mechanism for verifying that a request was submitted from the legitimate form.
On the other hand, SameSite is a mechanism that restricts the conditions under which a browser sends cookies with cross-site requests.
In other words:
CSRF token
= The server validates a token included in the request
SameSite
= The browser restricts the conditions under which cookies are sent
Each mechanism has a different role.
Rather than relying on a single security measure, we combine different mechanisms to reduce the risk of CSRF and other attacks through defense in depth.
HSTS
In addition to protecting the session cookie, we also configure the website itself to use HTTPS.
When the connection uses HTTPS, we set:
header('Strict-Transport-Security: max-age=31536000');This is known as HSTS (HTTP Strict Transport Security).
HSTS instructs the browser to use HTTPS rather than HTTP when connecting to the website for a specified period of time.
For example, even if a user accesses an HTTP URL, the browser will use HTTPS when HSTS is active.
HSTS must be sent in a response received over HTTPS.
Our implementation also sends this header only when the connection is using HTTPS.
The Difference Between Secure and HSTS
The Secure attribute and HSTS are both related to HTTPS, but they serve different purposes.
The Secure attribute means:
“Send the session cookie only over HTTPS connections.”
It is a setting applied to the cookie.
HSTS, on the other hand, means:
“Tell the browser to connect to the website using HTTPS.”
In other words:
Secure
↓
Send the cookie only over HTTPS
HSTS
↓
Use HTTPS when connecting to the website
This is the difference between the two.
In our form, we use Secure to protect the cookie, HttpOnly to restrict JavaScript access to the cookie, and SameSite to restrict cookie transmission in cross-site requests.
We also use HSTS to protect the connection to the website itself.
By combining security measures with different roles in this way, we protect the session through multiple layers.
XSS Protection
XSS stands for Cross-Site Scripting.
It is a vulnerability in which malicious scripts are inserted into user-submitted input or other data and are subsequently executed on a web page.
For example, if a user’s name or inquiry message is output directly as HTML, the input may be interpreted as HTML tags or JavaScript depending on its contents.
Because a contact form displays user-submitted information on a confirmation page and elsewhere, particular care must be taken when outputting input values into HTML.
Escape Data When Outputting It into HTML
On the confirmation page, we use the following function to escape user input before outputting it into HTML.
function e(string $value): string { return htmlspecialchars($value, ENT_QUOTES, 'UTF-8'); }htmlspecialchars() is a PHP function that converts characters with special meaning in HTML into HTML entities.
For example, converting <, >, &, and quotation marks prevents the input from being interpreted as HTML tags or attributes.
In this function:
ENT_QUOTESis specified to convert both single and double quotation marksUTF-8is specified to explicitly define the character encoding
For example, the confirmation page outputs values like this:
= e($form['your-name']) ?>
Instead of outputting user-submitted values directly into HTML, we escape them at the time of output.
Why Escape Data at Output?
It is not sufficient to determine whether user input is safe simply by checking whether it contains "dangerous characters."
A string that appears safe at one point may become problematic if the same data is later output as HTML somewhere else.
For this reason, it is important to generally treat data received from users as untrusted and apply appropriate escaping according to the context in which the data is output.
Validation and Escaping Are Different Measures
An important point here is that input validation and output escaping have different purposes.
Validation asks:
"Should this input be accepted?"
Escaping, on the other hand, means:
"How can this value be safely handled within HTML or another output context?"
For example, setting a maximum length for a name field or checking whether an email address has a valid format is validation.
Using htmlspecialchars() when displaying a name or inquiry message on the confirmation page is output escaping.
Therefore, rather than thinking:
"Input validation makes the data safe."
the important approach is:
"Validate the data appropriately when receiving it, and escape it appropriately when outputting it."
Each stage needs its own security considerations.
The Appropriate Protection Depends on Where the Data Is Output
The appropriate escaping method depends on where the data is being output.
On our confirmation page, user input is output as HTML content, so we use htmlspecialchars().
However, when outputting values into JavaScript, HTML attributes, URLs, CSS, or other contexts, different handling may be required.
Therefore, it is not correct to assume that "using htmlspecialchars() on every input makes it safe in every situation."
For this form, we appropriately escape the locations where user input is actually output into HTML.
Server-Side Validation
The HTML required attribute and JavaScript-based validation alone are not sufficient as security measures.
This is because HTML and JavaScript executed in the browser can be modified or disabled by the user.
For example, a user can use browser developer tools to remove the required attribute, bypass JavaScript validation, or send an HTTP request directly without going through the form.
For this reason, our form validates input not only in the browser but also on the server.
The Roles of Client-Side and Server-Side Validation
Client-side validation is primarily used to reduce user input errors and make the form easier to use.
Server-side validation, on the other hand, is used to determine whether the data actually received by the server can be trusted.
In other words:
Client-side validation
↓
Improve usability for users
Server-side validation
↓
Validate the data received by the server
These two mechanisms have different roles.
Any validation that is important for security must also be performed on the server.
Validate in Both confirm.php and send.php
Our form performs validation not only in confirm.php, but also in send.php, which handles the final email-sending process.
In confirm.php, the data submitted by the form is checked and the confirmation page is displayed if everything is valid.
Then, in send.php, the data stored in the session is retrieved and checked again to determine whether it is valid for final email delivery.
By validating again in send.php, we avoid treating data that has merely passed the confirmation process as automatically trustworthy.
Allowlist
For input validation, we use not only the idea of "excluding dangerous values," but also the concept of an Allowlist, which means accepting only values that have been explicitly permitted in advance.
For example, the inquiry type is defined as:
$allowed_inquiry_types = [ 'Inquiry about our services', 'Job opportunities', 'Other inquiries', ];
Only values included in this predefined list are accepted.
For fields where the available choices are predefined, we do not accept unexpected values.
Limiting Input Length
We set maximum lengths for fields such as names, company names, addresses, and inquiry messages.
This prevents the server from accepting data that greatly exceeds the expected range of the form.
Length restrictions are important not only for the visual design of the input fields, but also because they clearly define the range of data that the server is expected to accept.
Validating Email Addresses
For email addresses, we use:
filter_var($email, FILTER_VALIDATE_EMAIL)
to validate whether the input has a valid email address format.
We also set a maximum length for email addresses.
Validating Postal Codes and Phone Numbers
Postal codes and phone numbers are also validated on the server.
For example, we check whether a postal code follows the expected format and whether a phone number contains only the characters and format that we allow.
In this way, we define rules for each field specifying what kind of value can be accepted.
Confirming Agreement to the Privacy Policy
The contact form requires users to agree to the privacy policy.
This agreement is also verified on the server rather than relying solely on the browser-side checkbox.
The Principle of Not Trusting Input
One of the most important principles of server-side validation is not to trust data submitted by the user from the outset.
Even if a normal user submits data through the normal form, the server cannot simply determine whether that data has been modified.
Therefore, the server checks:
- Whether the value is allowed
- Whether required fields have been completed
- Whether the value has the appropriate format
- Whether the value is within the expected length
- Whether required agreements have been accepted
and rejects unexpected data.
Our Validation Flow
Our form validates input according to the following process:
User enters information
↓
Client-side validation
↓
Server-side validation in confirm.php
↓
Store data in the session
↓
Final validation in send.php
↓
Send email if everything is valid
Client-side validation is performed for usability, while security-critical validation is also performed on the server.
This is the basic principle behind the validation process in our form.

This photo was taken from Yorishima, Asakuchi City, Okayama, on August 19, 2026. Just a little break…
Email Header Injection Protection
In a contact form, the user's email address is used when sending the notification email to the administrator.
Because user-submitted values may be used in email headers, this area also needs to be handled carefully.
What Is Email Header Injection?
Email header injection is an attack in which an attacker inserts line breaks or other characters into user input in order to add email headers that were not intended by the developer.
Email messages contain information such as From, To, Reply-To, and Subject in their headers.
Therefore, if user-submitted values are used directly in email headers, an attacker may be able to add unintended headers or manipulate the contents of an email.
Fixing the From Address
In our form, we do not use the email address entered by the user as the From address.
Instead, the From address is fixed to an email address under our own domain.
$from = 'hanako.yamada@aedi.jp';
By not directly using user input as the From address, we prevent the sender address from being changed based on user-submitted input.
Using an email address under our own domain as the sender also allows us to manage the sender consistently.
Using the User's Email Address as Reply-To
So what do we do with the email address entered by the user?
We use it as the Reply-To address.
By setting Reply-To, the administrator can reply to the inquiry and have the user's email address automatically used as the destination.
In other words:
From
↓
AEDI email address
Reply-To
↓
User's email address submitted through the form
However, Reply-To is also an email header.
Therefore, simply using Reply-To instead of From does not automatically make the implementation safe. When user input is used as Reply-To, it must also be appropriately validated.
Checking for Line Breaks
Our form checks whether the email address contains line break characters.
if ( str_contains($email, "\r") || str_contains($email, "\n") ) { $errors[] = 'Invalid email address.'; }Because line breaks have a special meaning in email headers, we reject user input containing carriage returns or line feeds.
This reduces the risk of unintended information being added to email headers.
The email address is also checked using the server-side validation described in the previous section, including format validation and maximum length restrictions.
Using Plain Text for the Email Body
Our form sends email messages as plain text rather than HTML email.
Content-Type: text/plain; charset=UTF-8
With HTML email, HTML tags and other markup in the message body may be interpreted.
By using plain text instead, user input is not interpreted as HTML and the email can be sent in a simpler format.
Because there is no need for HTML email in this contact form, we use plain text.
Why We Separate From and Reply-To
Our email configuration uses:
From
= Fixed AEDI email address
Reply-To
= User's email address
Rather than directly using user input as the From address, we fix the From address and use the user's email address as the reply destination.
However, Reply-To also contains user input and is an email header, so we perform checks such as rejecting line breaks and validating the email address format.
When user-submitted values are used in email headers, it is important not only to consider which header they are used in, but also to verify that the values are in an appropriate format on the server.
Security Headers
For this form, we set several HTTP response headers in PHP to provide additional security measures on the browser side.
The headers we configure are:
header('X-Content-Type-Options: nosniff'); header('Referrer-Policy: strict-origin-when-cross-origin'); header('X-Frame-Options: SAMEORIGIN'); header("Content-Security-Policy: frame-ancestors 'self'"); header('Permissions-Policy: camera=(), microphone=(), geolocation=()');Each of these headers serves a different purpose.
X-Content-Type-Options
Setting X-Content-Type-Options: nosniff prevents the browser from attempting to infer the MIME type of a response on its own.
For our form, we use:
header('X-Content-Type-Options: nosniff');This helps ensure that the browser does not interpret content as a different MIME type than the one specified by the server, reducing the risk of content being handled in an unintended way.
Referrer-Policy
Referrer-Policy controls how much Referer information the browser sends when the user navigates to another website or makes certain requests.
Our form uses:
header('Referrer-Policy: strict-origin-when-cross-origin');With strict-origin-when-cross-origin, relatively detailed Referer information is sent for same-origin requests, while only the origin is generally sent for cross-origin requests.
When navigating from HTTPS to HTTP, which is a less secure connection, Referer information is not sent.
This helps prevent information contained in URLs from being unnecessarily transmitted to external websites.
X-Frame-Options
X-Frame-Options is a header that controls whether a page can be embedded by another website using mechanisms such as an iframe.
Our form uses:
header('X-Frame-Options: SAMEORIGIN');This allows the page to be displayed in a frame when the parent page is from the same origin.
This restricts third-party websites from embedding the page in an iframe and provides a layer of protection against clickjacking.
Content-Security-Policy
Content-Security-Policy (CSP) is a security feature that allows a website to control which resources the browser can load and how the page can be used.
CSP provides many different directives. For this form, we use:
header("Content-Security-Policy: frame-ancestors 'self'");to configure the frame-ancestors directive.
frame-ancestors 'self' restricts the parent pages that are allowed to display this page inside a frame, such as an iframe, to the same origin.
This prevents third-party websites from embedding the page in a frame and provides protection against clickjacking.
The Relationship Between X-Frame-Options and CSP
X-Frame-Options and the CSP frame-ancestors directive have overlapping roles because both can be used to restrict the embedding of a page in a frame.
Our form sets both:
X-Frame-Options: SAMEORIGIN
and:
Content-Security-Policy: frame-ancestors 'self'
By combining multiple settings that address the same general security concern, we create an additional layer of clickjacking protection that also takes different browser environments into consideration.
However, CSP is a much broader mechanism than frame control alone. It can also control the sources from which scripts, styles, images, fonts, and other resources can be loaded.
For this implementation, after considering the overall website structure and its external resources, we have configured only the necessary frame-ancestors directive.
Permissions-Policy
Permissions-Policy is a header that controls the extent to which certain browser features can be used by a website or by content within its frames.
Our form uses:
header('Permissions-Policy: camera=(), microphone=(), geolocation=()');This explicitly disables the following browser features for this website:
- Camera
- Microphone
- Geolocation
Because the website does not need any of these features, we restrict access to browser capabilities that are not required.
Security Headers Used in This Form
Our form combines several HTTP response headers, each with a different purpose.
X-Content-Type-Options
↓
Prevent MIME type sniffing
Referrer-Policy
↓
Control the transmission of Referer information
X-Frame-Options
↓
Restrict embedding through iframes and similar mechanisms
Content-Security-Policy
↓
In this case, restrict frame embedding using frame-ancestors
Permissions-Policy
↓
Restrict unnecessary browser features
In this way, we combine security headers with different purposes rather than relying on a single configuration.
Protection Against Duplicate Submissions
In a contact form, the same inquiry may be submitted multiple times if the user clicks the submit button repeatedly or uses browser operations such as Back or Resubmit after submitting the form.
In our implementation, once the process moves from the confirmation page to the final submission, we delete the inquiry data stored in the session.
unset($_SESSION['contact_form']);
This is executed in send.php after final validation has been completed and immediately before the email-sending process begins.
In other words, once the submission process begins, the inquiry data is removed from the session.
This makes it more difficult to submit the same inquiry again using the same inquiry data stored in the session.
After the email-sending process is completed, the CSRF token is also destroyed.
unset($_SESSION['csrf_token']);
This makes it more difficult to reuse a CSRF token that has already been used.
The overall process can be summarized as follows:
confirm.php
↓
Store inquiry data in the session
↓
send.php
↓
Final validation
↓
Delete $_SESSION['contact_form']
↓
Send notification email
↓
Send automatic reply email
↓
Delete $_SESSION['csrf_token']
↓
Redirect to thanks.html
However, this does not completely prevent duplicate submissions in every possible situation.
Our implementation is designed to make it more difficult to resubmit the same confirmed data by destroying the submitted inquiry data and CSRF token after the submission process begins.
If the notification email to the administrator fails, send.php returns HTTP status code 500 and stops processing. On the other hand, if the automatic reply email fails after the notification email has been successfully sent, the inquiry itself is considered successful and the user proceeds to thanks.html.
Spam Protection Using a Honeypot
In addition to security measures, protecting contact forms against unwanted automated submissions, or spam, is also important.
For this form, we use a technique called a Honeypot.
What Is a Honeypot?
A Honeypot is a technique in which an input field is added to a form that normal human users are not expected to fill in.
If a value is entered into that field, the submission can be considered likely to have been generated by an automated spam program.
The field is normally hidden from human users.
However, a simple form automation program may detect all input fields in the form and automatically enter a value into the Honeypot field as well.
When this happens, the server rejects the submission.
Creating a Honeypot Field
Our form includes an input field like the following, using Bootstrap:
<div class="mb-5 row d-none" aria-hidden="true">
<div class="col-xl-3">
<label for="website" class="col-form-label">Website<span class="required">Required</span>: </label>
</div>
<div class="col-xl-3 col-md-6">
<input type="text" name="website" id="website" tabindex="-1" autocomplete="off" >
</div>
</div>
This field is not displayed to normal users.
We also set tabindex="-1" so that it is excluded from normal keyboard navigation through the form.
We set autocomplete="off" as well, making the field less likely to be automatically filled by the browser.
Checking the Honeypot on the Server
Simply hiding the Honeypot field is not enough.
The server must check whether a value has been entered into the field.
In confirm.php, we use:
$honeypot = trim((string)($_POST['website'] ?? '')); if ($honeypot !== '') { http_response_code(403); exit('Invalid request.'); }If the Honeypot field contains a value, the request is treated as invalid and processing is stopped.
When a human user submits the form normally, we expect this field to remain empty.
Therefore, if it contains a value, we consider the submission potentially generated by an automated spam program.
Why We Chose a Honeypot
This time, rather than introducing an external service such as reCAPTCHA, we decided to start with a Honeypot-based spam protection mechanism.
One reason is that users do not need to perform any additional actions such as image verification or checking a checkbox. The form can be used normally without adding extra steps.
Another advantage is that it can be implemented relatively simply within the form itself without relying on an external service.
A Honeypot Has Limitations
However, a Honeypot cannot prevent all spam submissions.
If a spam program recognizes the existence of the Honeypot or becomes capable of taking JavaScript and CSS into account and imitating human behavior, it may be able to bypass this technique.
Therefore, we do not consider the Honeypot to be a mechanism that can completely prevent spam. It is one layer of spam protection implemented in this particular form.
If spam submissions increase in the future, we may consider adding other measures such as submission frequency limits, rate limiting, Cloudflare Turnstile, or reCAPTCHA.
Our Honeypot Protection Flow
Our form uses the following process:
Create a Honeypot field
↓
Hide it from normal users
↓
Submit the form
↓
Check the Honeypot value on the server
↓
Reject the request if a value is present
This provides a relatively simple way to detect automated spam submissions without adding any additional steps for normal users.
There Is No Such Thing as "Perfect Security"
Even after implementing all of these measures, we cannot say that this form is 100% secure.
Security cannot protect against every possible risk with a single measure.
In our form, we consider different risks at each stage, from input through email delivery:
↓
Validation
↓
Session
↓
CSRF
↓
Output
↓
↓
HTTP headers
↓
Spam protection
The important thing is not to think, "We have built something that is completely secure."
Instead, we believe it is important to identify the risks that can reasonably be anticipated at the time and reduce those risks as much as possible, one by one.
Defense in Depth
Our form combines multiple security measures, including CSRF protection, session cookie protection, server-side validation, XSS protection, email header injection protection, security headers, and a Honeypot.
This is closely related to the concept known as "Defense in Depth."
Defense in Depth means reducing overall risk by combining multiple layers of different security measures rather than relying on a single defense mechanism.
For example, we combine input validation with output escaping, CSRF protection, session cookie protection, and email header validation so that the system does not depend on any one security measure.
Of course, implementing multiple security measures does not mean that attacks can be completely prevented.
However, by placing multiple layers of defense in different parts of the system rather than relying on a single measure, we can reduce individual risks and work toward a more secure overall architecture.
Security Is an Ongoing Process
A website is not finished simply because it has been built and published.
Software and services such as PHP, web servers, email systems, and browsers continue to change.
New vulnerabilities may be discovered, and new attack techniques may emerge.
For this reason, it is important to review configurations and implementations after publication and improve them as necessary.
The security measures implemented in this form are not the end of the process.
If new risks are identified or more appropriate security measures become available, we intend to improve the implementation as necessary.
For this reason, we believe website security should be treated as an ongoing cycle:
↓
Publication
↓
Operation
↓
Review
↓
Improvement
Security Checklist
Here is a checklist summarizing the security measures implemented in this contact form.
CSRF
- Generate a CSRF token
- Store the CSRF token in the session
- Validate the submitted CSRF token
- Use
hash_equals() - Validate the CSRF token again during final submission
- Destroy the CSRF token after submission
Session
- Enable
session.use_only_cookies - Enable
session.use_strict_mode - Use Secure cookies over HTTPS
- Set HttpOnly
- Set SameSite=Lax
- Regenerate the session ID
XSS
- Do not trust user input
- Validate input on the server
- Use
htmlspecialchars()when outputting HTML - Do not output user input directly as HTML
Validation
- Validate inquiry types using an Allowlist
- Check required fields on the server
- Set maximum lengths for each field
- Validate the postal code format
- Validate the phone number format
- Validate the email address format
- Set a maximum length for email addresses
- Confirm agreement to the privacy policy
- Validate data in
confirm.php - Perform final validation in
send.php
- Fix the From address to an email address under our own domain
- Use the user's email address as Reply-To
- Reject line breaks in email addresses
- Send email bodies as plain text
- Do not use user input as the From address
HTTP Security Headers
- HSTS (Strict-Transport-Security)
- X-Content-Type-Options: nosniff
- Referrer-Policy: strict-origin-when-cross-origin
- X-Frame-Options: SAMEORIGIN
- Content-Security-Policy: frame-ancestors 'self'
- Permissions-Policy
Spam Protection
- Add a Honeypot field
- Check the Honeypot value on the server
- Reject the request if the Honeypot contains a value
Form Submission and Operation
- Destroy submitted session data
- Do not expose internal information when an error occurs
- Return HTTP 500 when sending the administrator notification email fails
Conclusion
When building this contact form for a static website, we did not simply think about how to "send the submitted information by email." Instead, we designed and implemented the form while considering the various risks that can occur throughout the process.
The final architecture looks like this:
↓
CSRF
↓
confirm.php
↓
Input validation
Session management
Honeypot
↓
send.php
↓
Final validation
↓
Email header protection
↓
Email delivery
↓
thanks.html
We combined multiple security measures that address different risks, including CSRF protection, session management, XSS protection, server-side validation, email header injection protection, security headers, and a Honeypot.
Static websites can be built relatively simply using HTML, CSS, and JavaScript. However, when server-side functionality such as a contact form is added, there are more things that we need to consider ourselves.
Unlike an environment where a form plugin can be used with WordPress, we need to design everything ourselves, including how form data is received, where it is validated, how it is managed through sessions, and how it is ultimately sent by email.
Through this project, I was reminded once again that:
"A form is not enough just because it works."
It is important to understand not only the design, HTML, CSS, and JavaScript, but also what is happening on the server.
We need to consider where the data entered by users goes, how it is validated, how it is stored, and ultimately how it is sent as an email.
I believe this level of consideration is an important part of professional website development.
Of course, the measures implemented in this form do not make it completely secure.
Website security does not end when a website is built and published. It is important to monitor the website during operation and add or improve security measures as necessary in response to new information, vulnerabilities, and attack techniques.
We will also continue to monitor this form during operation and make improvements when necessary.
I hope this article gives you an opportunity to think not only about "making a form work," but also about "how to make it work securely" when implementing a contact form on a static website.


