Anti-CSRF
Protect Cloud Page forms against cross-site request forgery using a server-generated token stored in a Data Extension.
Cross-site request forgery (CSRF) attacks trick a user’s browser into submitting an authenticated request to your Cloud Page without their knowledge. The standard defence is a server-generated, single-use token that is tied to the user’s session and verified on submission.
Pattern Overview
- Render phase — generate a random token, store it alongside the subscriber key in a DE, embed it in the form as a hidden field.
- Submit phase — read the submitted token, look it up in the DE, verify it matches the expected subscriber key, then delete it so it cannot be replayed.
Show test script
<script runat="server">
/*
* Chapter: Pattern Overview
* Proves:
* 1. GUID token + Variable storage pattern.
* EXPECTED OUTPUT: every line starts with PASS.
*/
function assert(id, actual, expected) {
Platform.Response.Write((actual === expected ? "PASS " : "FAIL ") + id + " -> [" + actual + "]\n");
}
function assertThrows(id, fn) {
var threw = false, msg = "";
try { fn(); } catch (ex) { threw = true; msg = "" + ex.message; }
Platform.Response.Write((threw ? "PASS " : "FAIL ") + id + " -> " + (threw ? "threw: " + msg : "did NOT throw") + "\n");
}
function typeOfThunk(fn) {
try { return "" + fn(); } catch (ex) { return "THREW:" + ("" + ex.message); }
}
var token = Platform.Function.GUID();
Platform.Variable.SetValue("@csrf", token);
assert("token round-trip", Platform.Variable.GetValue("@csrf"), token);
</script>
Setup: Token Storage DE
Create a Data Extension named CSRFTokens with these fields:
| Field | Type | Is Primary Key |
|---|---|---|
Token |
Text (50) | Yes |
SubscriberKey |
Text (254) | No |
CreatedAt |
Date | No |
Show test script
<script runat="server">
/*
* Chapter: Setup Token Storage DE
* Proves:
* 1. NON-ASSERTABLE: DE schema.
* InsertData/Lookup typeof.
* EXPECTED OUTPUT: every line starts with PASS.
*/
function assert(id, actual, expected) {
Platform.Response.Write((actual === expected ? "PASS " : "FAIL ") + id + " -> [" + actual + "]\n");
}
function assertThrows(id, fn) {
var threw = false, msg = "";
try { fn(); } catch (ex) { threw = true; msg = "" + ex.message; }
Platform.Response.Write((threw ? "PASS " : "FAIL ") + id + " -> " + (threw ? "threw: " + msg : "did NOT throw") + "\n");
}
function typeOfThunk(fn) {
try { return "" + fn(); } catch (ex) { return "THREW:" + ("" + ex.message); }
}
assert("InsertData", typeof Platform.Function.InsertData, "clrmethodinfo");
assert("Lookup", typeof Platform.Function.Lookup, "clrmethodinfo");
</script>
Step 1 — Render the Form
Platform.Load("core", "1.1.5");
var subKey = Platform.Variable.GetValue("@SubscriberKey");
if (!subKey) { subKey = "anon_" + Platform.Function.GUID(); }
// Generate a random token and store it
var token = Platform.Function.GUID();
Platform.Function.InsertData(
"CSRFTokens",
["Token", "SubscriberKey", "CreatedAt"],
[token, subKey, Platform.Function.Now()]
);
Embed the token in your form:
<form method="post" action="%%=CloudPagesURL(123)=%%">
<input type="hidden" name="csrfToken" value="%%=v(@token)=%%">
<!-- other form fields -->
<button type="submit">Submit</button>
</form>
Note: The form value is set via AMPscript’s
%%=v(@token)=%%after assigning the SSJS variable to an AMPscript variable withVariable.SetValue("@token", token).
Platform.Variable.SetValue("@token", token);
Show test script
<script runat="server">
/*
* Chapter: Step 1 Render the Form
* Proves:
* 1. GUID non-empty for hidden field.
* EXPECTED OUTPUT: every line starts with PASS.
*/
function assert(id, actual, expected) {
Platform.Response.Write((actual === expected ? "PASS " : "FAIL ") + id + " -> [" + actual + "]\n");
}
function assertThrows(id, fn) {
var threw = false, msg = "";
try { fn(); } catch (ex) { threw = true; msg = "" + ex.message; }
Platform.Response.Write((threw ? "PASS " : "FAIL ") + id + " -> " + (threw ? "threw: " + msg : "did NOT throw") + "\n");
}
function typeOfThunk(fn) {
try { return "" + fn(); } catch (ex) { return "THREW:" + ("" + ex.message); }
}
var token = Platform.Function.GUID();
assert("token length", ("" + token).replace(/-/g, "").length >= 32 ? "true" : "false", "true");
</script>
Step 2 — Validate on Submission
Platform.Load("core", "1.1.5");
var submittedToken = Platform.Request.GetFormField("csrfToken");
var subKey = Platform.Variable.GetValue("@SubscriberKey");
if (!submittedToken) {
Write(Platform.Function.Stringify({status: 403, statusMessage: "Forbidden", error: "Missing security token."}));
Platform.Function.RaiseError("Missing CSRF token", true);
}
// Look up the token
// String() first — a Lookup result throws on a truthiness test when the field is empty
var storedKey = String(Platform.Function.Lookup(
"CSRFTokens", "SubscriberKey", "Token", submittedToken
));
if (storedKey === "" || storedKey === "null" || storedKey !== subKey) {
Write(Platform.Function.Stringify({status: 403, statusMessage: "Forbidden", error:"Invalid or expired security token."}));
Platform.Function.RaiseError("CSRF token mismatch", true);
}
// Token is valid — delete it immediately (single-use)
Platform.Function.DeleteData("CSRFTokens", ["Token"], [submittedToken]);
// Continue with form processing...
Show test script
<script runat="server">
/*
* Chapter: Step 2 Validate on Submission
* Proves:
* 1. Missing token → 403 shape; mismatch reject.
* EXPECTED OUTPUT: every line starts with PASS.
*/
function assert(id, actual, expected) {
Platform.Response.Write((actual === expected ? "PASS " : "FAIL ") + id + " -> [" + actual + "]\n");
}
function assertThrows(id, fn) {
var threw = false, msg = "";
try { fn(); } catch (ex) { threw = true; msg = "" + ex.message; }
Platform.Response.Write((threw ? "PASS " : "FAIL ") + id + " -> " + (threw ? "threw: " + msg : "did NOT throw") + "\n");
}
function typeOfThunk(fn) {
try { return "" + fn(); } catch (ex) { return "THREW:" + ("" + ex.message); }
}
Platform.Load("core", "1.1.5");
function validateCsrf(posted, expected) {
if (!posted) return { status: 403, error: "Missing security token." };
if (posted !== expected) return { status: 403, error: "Invalid or expired security token." };
return { status: 200 };
}
var miss = validateCsrf("", "abc");
assert("missing 403", miss.status, 403);
var bad = validateCsrf("x", "abc");
assert("mismatch 403", bad.status, 403);
var ok = validateCsrf("abc", "abc");
assert("ok 200", ok.status, 200);
assert("Stringify 403", Platform.Function.Stringify(miss).indexOf("403") >= 0 ? "true" : "false", "true");
</script>
Cleanup: Expire Old Tokens
Tokens should not accumulate. Run this in an Automation on a schedule (e.g. hourly) to remove tokens older than 30 minutes:
var proxy = new Script.Util.WSProxy();
function dateAdd(timestamp,intervalToAdd,intervalType) {
Platform.Variable.SetValue("@dateAdd_ts",timestamp);
Platform.Variable.SetValue("@dateAdd_add",intervalToAdd);
Platform.Variable.SetValue("@dateAdd_type",intervalType);
return Platform.Function.TreatAsContent("%%=DateAdd(@dateAdd_ts, @dateAdd_add, @dateAdd_type)=%%");
}
var cutoff = dateAdd(Platform.Function.Now(), -30, "MI");
var filter = {
Property: "CreatedAt",
SimpleOperator: "lessThan",
Value: cutoff
};
var stale = proxy.retrieve("DataExtensionObject[CSRFTokens]", ["Token"], filter);
for (var i = 0; i < stale.Results.length; i++) {
Platform.Function.DeleteData("CSRFTokens", ["Token"], [stale.Results[i].Token]);
}
Show test script
<script runat="server">
/*
* Chapter: Cleanup Expire Old Tokens
* Proves:
* 1. dateAdd via TreatAsContent returns a string.
* EXPECTED OUTPUT: every line starts with PASS.
*/
function assert(id, actual, expected) {
Platform.Response.Write((actual === expected ? "PASS " : "FAIL ") + id + " -> [" + actual + "]\n");
}
function assertThrows(id, fn) {
var threw = false, msg = "";
try { fn(); } catch (ex) { threw = true; msg = "" + ex.message; }
Platform.Response.Write((threw ? "PASS " : "FAIL ") + id + " -> " + (threw ? "threw: " + msg : "did NOT throw") + "\n");
}
function typeOfThunk(fn) {
try { return "" + fn(); } catch (ex) { return "THREW:" + ("" + ex.message); }
}
function dateAdd(timestamp, intervalToAdd, intervalType) {
Platform.Variable.SetValue("@dateAdd_ts", timestamp);
Platform.Variable.SetValue("@dateAdd_add", intervalToAdd);
Platform.Variable.SetValue("@dateAdd_type", intervalType);
return Platform.Function.TreatAsContent("%%=DateAdd(@dateAdd_ts, @dateAdd_add, @dateAdd_type)=%%");
}
var cutoff = dateAdd(Platform.Function.Now(), -30, "MI");
assert("cutoff non-empty", ("" + cutoff).length > 0 ? "true" : "false", "true");
</script>