Security Guide
This document outlines the security measures implemented across our web projects. It serves as a reference for developers and as a handoff document for security audits.
1. Content Security Policy (CSP)
What is CSP
A Content Security Policy is an HTTP response header that tells the browser which sources are allowed to load scripts, styles, fonts, images and other resources. It is one of the most effective defences against Cross-Site Scripting (XSS) attacks.
Where to set it
The CSP header must be set in PHP — not in .htaccess. This is required because the nonce (see below) is dynamically generated per request and cannot be done in a static .htaccess file.
Remove any existing Header set Content-Security-Policy line from .htaccess when moving to PHP.
Generating a nonce
A nonce is a unique, random value generated on every page request. It is used to whitelist specific inline scripts and styles without opening up unsafe-inline for everything.
Place this at the very top of your index.php, before any HTML output:
<?php
// Must be the absolute first line — no blank lines or spaces above this
$nonce = base64_encode(random_bytes(16));
header("Content-Security-Policy: " .
"script-src 'self' 'nonce-{$nonce}' https://cdn.jsdelivr.net https://cdnjs.cloudflare.com; " .
"style-src 'self' 'nonce-{$nonce}' https://fonts.googleapis.com; " .
"object-src 'none'; " .
"base-uri 'self';"
);
?>
<!DOCTYPE html>
<html>
...
⚠️ Any whitespace or output before
<?phpwill cause PHP to silently skip theheader()call. You'll see aCannot modify header information — headers already sentwarning if this happens.
Applying the nonce to inline tags
Every inline <script> and <style> block must include the nonce attribute:
<!-- Inline script -->
<script nonce="<?= $nonce ?>">
window.__CONFIG__ = { env: 'production' };
</script>
<!-- Inline style -->
<style nonce="<?= $nonce ?>">
:root { --brand-color: #0055ff; }
</style>
External files loaded via src or href do not need a nonce — they are covered by the domain allowlist:
<!-- No nonce needed — domain is in script-src -->
<script src="https://cdn.jsdelivr.net/npm/somelib/dist/somelib.min.js"></script>
<!-- No nonce needed — domain is in style-src -->
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter">
Directives to avoid
| Directive | Risk | Alternative |
|---|---|---|
unsafe-inline | Allows any inline script or style | Use nonces or hashes |
unsafe-hashes | Whitelists inline event handlers | Move to addEventListener() |
unsafe-eval | Allows eval() and new Function() | Refactor code to avoid eval |
Inline event handlers
unsafe-hashes is typically needed when HTML contains inline event handlers like onclick="...". The fix is to move these into external JS files:
<!-- ❌ Before -->
<button onclick="doSomething()">Click</button>
<!-- ✅ After -->
<button id="my-btn">Click</button>
// In your external JS file
document.getElementById('my-btn').addEventListener('click', doSomething);
GTM & third-party scripts
Google Tag Manager injects inline scripts into the page. To cover these with your nonce:
- GTM → Admin → Container Settings
- Enable "Add nonce to dynamically injected scripts"
- Save and publish the container
Scripts loaded through GTM (e.g. Cookiebot, analytics tags) will inherit the nonce automatically.
For Cookiebot loaded directly (not via GTM), add data-nonce to the script tag:
<script
id="Cookiebot"
src="https://consent.cookiebot.com/uc.js"
data-cbid="YOUR-CBID"
data-nonce="<?= $nonce ?>"
nonce="<?= $nonce ?>">
</script>
Validating your CSP
- DevTools → Network → page request → Response Headers →
Content-Security-Policy - Console → any blocked resource shows a red
Refused to executeerror - CSP Evaluator → paste your policy at https://csp-evaluator.withgoogle.com/
- Report-Only mode → use
Content-Security-Policy-Report-Onlyto log violations without blocking, useful for testing before enforcing
frame-ancestors exception
If a page needs to be embedded from an unknown or variable origin (e.g. a kiosk or booth deployment), frame-ancestors * can be set in the CSP. This must be a documented, client-approved exception:
"frame-ancestors *;"
// Document reason: required for kiosk/booth deployment — approved by client on [date]
2. Subresource Integrity (SRI)
What is SRI
SRI ensures that externally loaded static resources (scripts, stylesheets) have not been tampered with. The browser computes a hash of the fetched file and compares it against the hash in the HTML. If they don't match, the resource is blocked.
When to use it
SRI should be applied to any static, versioned resource loaded from a CDN. It should not be applied to dynamic scripts that are continuously updated by the vendor (see exemptions below).
How to add it
Generate the hash at https://www.srihash.org/ and add integrity and crossorigin attributes:
<!-- Stylesheet with SRI -->
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/@fancyapps/ui@4.0/dist/fancybox.css"
integrity="sha256-3e3RCRzozjqLEcmEZnSoKv/VL6BFlGxofHUWCZ3BXGA="
crossorigin="anonymous">
<!-- Script with SRI -->
<script
src="https://cdnjs.cloudflare.com/ajax/libs/slim-select/1.27.1/slimselect.min.js"
integrity="sha256-HASH_HERE"
crossorigin="anonymous">
</script>
Checking for missing SRI
Use the sri-check CLI tool to scan a page for any resources missing integrity attributes:
# Install
pip install sri-check
# Run against a URL
sri-check https://yoursite.com/page/
Any flagged <script> or <link> tag should either have SRI added or be documented as an exemption.
Exemptions
Dynamic third-party scripts cannot have SRI because their content changes frequently. These should instead be covered by domain allowlisting in the CSP.
| Script | Reason for exemption |
|---|---|
| Google reCAPTCHA | Dynamically updated by Google |
| Google Tag Manager | Dynamically updated by Google |
| Google Analytics / gtag.js | Dynamically updated by Google |
| Hotjar | Dynamically updated by vendor |
Maintaining SRI hashes
SRI hashes are tied to a specific file version. If the library is updated, the hash must be updated too.
Process when updating a CDN library:
- Update the version number in the
hreforsrcURL - Run
sri-checkagainst the page or generate a new hash at https://www.srihash.org/ - Update the
integrityattribute in the HTML - Test in the browser to confirm no SRI errors in the console
Tip: Use pinned, exact version numbers in CDN URLs (e.g.
@4.0.12not@4) so the file never silently changes under you.
3. Security Headers
The following headers should be set in .htaccess. The CSP is the exception — it belongs in PHP (see section 1).
# Security Headers
Header always set X-Content-Type-Options "nosniff"
Header always set X-Frame-Options "SAMEORIGIN"
Header always set Referrer-Policy "strict-origin-when-cross-origin"
Header always set Permissions-Policy "geolocation=(), microphone=(), camera=()"
Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains"
Place these above the <IfModule mod_rewrite.c> block.
What each header does
| Header | Purpose |
|---|---|
X-Content-Type-Options: nosniff | Prevents browsers from MIME-sniffing a response away from the declared content type |
X-Frame-Options: SAMEORIGIN | Blocks the page from being embedded in an iframe on external domains (clickjacking protection) |
Referrer-Policy | Controls how much referrer information is sent with requests |
Permissions-Policy | Disables browser features (camera, mic, geolocation) that the site doesn't use |
Strict-Transport-Security | Forces HTTPS for 12 months — only add if the site is fully on HTTPS |
Validating headers
Check your score at https://securityheaders.com — paste your URL and it grades all headers. Target Grade A as a minimum.
4. Input Validation & Sanitization
Always sanitize and validate user input server-side. Never rely on client-side validation alone.
Sanitization
Strip or encode unexpected characters before using input:
$email = filter_var($email, FILTER_SANITIZE_EMAIL);
$url = filter_var($url, FILTER_SANITIZE_URL);
$text_field = strip_tags($text_field);
Validation
Confirm the value is the expected format after sanitizing:
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
// reject — not a valid email
}
if (!filter_var($url, FILTER_VALIDATE_URL) || !str_contains($url, 'expecteddomain.com')) {
// reject — not a valid or expected URL
}
Output escaping
Any value echoed back into HTML must be wrapped in htmlspecialchars() at the point of output, regardless of earlier sanitization:
echo htmlspecialchars($user_input, ENT_QUOTES, 'UTF-8');
This is the last line of defence against XSS and should always be applied.
Sanitize vs validate
| Step | What it does | When |
|---|---|---|
| Sanitize | Cleans the value — removes/encodes unexpected characters | Before storing or using input |
| Validate | Confirms it matches the expected format | Always, after sanitizing |
5. reCAPTCHA v3 — Server-side Verification
Loading reCAPTCHA on the frontend is not enough — the token must be verified server-side. Without this, the check can be bypassed entirely.
Frontend — capture the token
grecaptcha.ready(function() {
grecaptcha.execute('YOUR_SITE_KEY', { action: 'submit' })
.then(function(token) {
document.getElementById('recaptcha_token').value = token;
});
});
<input type="hidden" id="recaptcha_token" name="recaptcha_token">
Backend — verify with Google
$secret_key = 'YOUR_SECRET_KEY'; // from Google reCAPTCHA console
$token = $_POST['recaptcha_token'] ?? '';
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, "https://www.google.com/recaptcha/api/siteverify");
curl_setopt($ch, CURLOPT_POST, 1);
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query([
'secret' => $secret_key,
'response' => $token
]));
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$response = curl_exec($ch);
curl_close($ch);
$result = json_decode($response, true);
if (!$result['success'] || $result['score'] < 0.5) {
http_response_code(403);
exit('reCAPTCHA verification failed.');
}
// ✅ Passed — process the form
Score reference
| Score | Meaning |
|---|---|
1.0 | Very likely human |
0.5 | Recommended minimum threshold |
0.0 | Very likely bot |
Raise the threshold to 0.7 if spam is getting through.
6. Tools Reference
| Tool | Purpose | URL |
|---|---|---|
sri-check | Scan pages for missing SRI attributes | https://github.com/4ARMED/sri-check |
| SRI Hash Generator | Generate integrity hashes for CDN resources | https://www.srihash.org/ |
| CSP Evaluator | Analyse and validate CSP policy | https://csp-evaluator.withgoogle.com/ |
| Security Headers | Grade HTTP response headers | https://securityheaders.com |
7. Checklist
Use this checklist for every new or audited project.