background shape
background shape

Securing SFMC Cloud Pages with SSJS

In Salesforce Marketing Cloud (SFMC), Cloud Pages are commonly used for developing and testing scripts. But once these pages are published, they become accessible to anyone, which can pose a security threat if they contain sensitive info like API tokens or personal information.

Additionally, if Cloud Pages are used as HTML forms without proper security measures, they can be exploited by spammers. This article focuses on securing Cloud Pages for development and internal use, not on other technical aspects like SSL, HTML headers, logging or captcha.

Options

To mitigate these risks and bolster security, developers can implement various measures. These require a trade-off between security and convenience, as the more secure options often require additional steps both during development and during testing.

Another thing to consider is, that there are multiple scenarios to cover. From normal Cloud Page (forms & unsubcribe pages, MC Apps), Script Activities and Custom Cloud Page Resources APIs. Each of the following options is suitable for different scenarios.

IP Whitelisting

This is probably the easiest approach to securing Cloud Pages. By whitelisting the IP addresses of the developers, the pages can only be accessed from the specified IP addresses. Let’s take a look at how this can be implemented.

Proposed Data Extension
Name and Extension Key: `devIPWhitelist`
Fields:
– `IP` (Text, 15)
– `allowed` (Boolean)
– `description` (Text, 100)

<script runat=server language="JavaScript">
	Platform.Load("core", "1.1.1");

	var IP_WHITELIST_DE = "devIPWhitelist";

	try {
		// get user IP:
		var clientIp = Platform.Request.ClientIP;
		// check whitelist:
		var allowed = Platform.Function.Lookup("devIPWhitelist", 'ip', ['ip', 'allowed'], [clientIp, true]) ? true : false;
		// set allow or not:
		Platform.Variable.SetValue('allowed', allowed);
	} catch(err) {
		Write("An error has occurred: " + String(err));
		Platform.Variable.SetValue('allowed', false);
	}
</script>
%%[ IF @allowed == true THEN ]%%
	You are authenticated.
%%[ ELSE ]%%
	You are not authenticated.
%%[ ENDIF ]%%

As you can see, this is a simple and effective way to secure the pages, but lacks when the developers are changing the location often without using VPN. It can also be more difficult to implement in case of Cloud Page APIs as you need to implement whole API ranges of SFMC.

URL Token

This approach involves appending a randomly generated token to the URL, effectively creating a simplified authentication. This token can be checked against a stored value in the page to ensure that only authorised users can access the page.

<script runat=server language="JavaScript">
	Platform.Load("core", "1.1.1");

	var TOKEN = "9262b3d2-cb91-4c4a-aca2-6e9f7af8eeb4";

	try {
		// Get the token from the request:
		var allowed = Request.GetQueryStringParameter("dev-token") === TOKEN;
		// Set the allowed:
		Platform.Variable.SetValue('allowed', allowed);
	} catch(err) {
		Write("An error has occurred: " + String(err));
		Platform.Variable.SetValue('allowed', false);
	}
</script>
%%[ IF @allowed == true THEN ]%%
	You are authenticated.
%%[ ELSE ]%%
	You are not authenticated.
%%[ ENDIF ]%%

This is yet another effective way to secure the pages, but the token should be changed regularly to increase security. This is a great approach to use, if you want to handle your custom Cloud-page APIs, as you can switch the `Platform.Request.GetQueryStringParameter()` for `Platform.Request.GetRequestHeader()` and use it similar to API tokes. It becomes more complicated, once you start using this for multi-page forms as the token should be passed to all of the form pages.

Login Form

This approach requires users to authenticate themselves before accessing the page via a custom login form (similar to HTTP Basic Auth).

<script runat="server">
  Platform.Load("core","1.1.1");

  var AUTH_DEFAULT = 'user:my-password-123';

  function getAuthFormValues() {
    var username = Platform.Request.GetFormField('username');
    var password = Platform.Request.GetFormField('password');
    return username + ':' + password;
  }

	try {
    var allowed = getAuthFormValues() === AUTH_DEFAULT;
		Variable.SetValue("allowed", allowed);
  } catch(err) {
		Write("An error has occurred: " + String(err));
		Platform.Variable.SetValue('allowed', false);
  }
</script>
%%[ IF @allowed == true THEN ]%%
	You are authenticated.
%%[ ELSE ]%%
	<h2>Login:</h2>
	<form method="post">
		<div>
			<label for="username">Username:</label>
			<input type="text" id="username" name="username" required>
		</div>
		<div>
			<label for="password">Password:</label>
			<input type="password" id="password" name="password" required>
		</div>
		<div>
			<input type="submit" value="Login">
		</div>
	</form>
%%[ ENDIF ]%%

This is also a rather simple solution, but without storing the credentials in the page, it will require developers to enter the credentials with every new page open. While it’s rather simple and secure, without those additional steps it will get tedious. Also, SSJS does not seem to offer any function to work with session storage, so it would require using for example cookies. Use `Platform.Response.SetCookie()` & `Platform.Request.GetCookieValue()`, but do not forget to hash the secrets!

Server-to-Server login

You might be asking: “why I wouldn’t use SFMC API credentials to do the heavy-lifting?” And that’s exaclty this (and the next) method. When using this proposed method, you use the server-to-server credentials to validate requests. In the context of the custom APIs, you could handle this as:

1) Authenticate via SFMC Auth API: the process begins with authenticating through the SFMC Auth endpoint to obtain a Bearer token, which serves as the token for subsequent step.
2) Passing Token to Custom SFMC API: Once authenticated, the acquired token is transmitted as an Authentication Header in requests made to the custom SFMC API.
3) Token Validation Check: A critical step involves validating the received token by initiating a basic request, such as GET /platform/v1/configcontext, to confirm its authenticity and validity.
4) Authentication Handling: Based on the validation result, the system proceeds with the intended actions if the token is deemed valid; otherwise, it rejects unauthorized access attempts, safeguarding against potential security threats.

In following example, let’s take a look at steps 2-4:

<script runat=server language="JavaScript">
	Platform.Load("core", "1.1.1");

	var SFMC_SETUP = {
		// only the organization part of the api path ('https://{subdomain}.auth.marketingcloud.com)
		'subdomain': '{{MC_SUBDOMAIN}}'
	};

	var mcApiTest = {
		setup: function (token) {
			this.authUrl = 'https://' + SFMC_SETUP.subdomain + '.auth.marketingcloudapis.com';
			this.restUrl = 'https://' + SFMC_SETUP.subdomain + '.rest.marketingcloudapis.com';

			this.token = token;
		},

		request: function (httpMethod, path, body, urlParams, useAuthUrl) {
			var result = {
				'ok': false,
				'body': 'API request ' + path + ' failed.'
			};

			try {
				var u = useAuthUrl === true ? this.authUrl : this.restUrl;
				var requestUrl = this.buildUrl(u, path, urlParams, false);

				var req = new Script.Util.HttpRequest(requestUrl);
				req.emptyContentHandling = 0;
				req.retries = 2;
				req.continueOnError = true;
				req.contentType = "application/json";
				var token = 'Bearer ' + this.token;
				req.setHeader("Authorization", token);
				req.setHeader("Accept", "application/json");
				req.method = httpMethod;

				if (body !== undefined) {
					if (typeof (body) === 'string') {
						req.postData = body;
					} else {
						req.postData = Stringify(body);
					}
				}
				
				var httpResult = req.send();
				
				var status = Number(httpResult.statusCode);
				if (status == 200 || status == 201 || status == 202 || status == 204) {
					result.ok = true;
				}
				result.body = httpResult.content + '';
				result.status = status;
			} catch (err) {
				throw "Error in request(): " + err + " - message: " + err.message;
			}
			return result;
		},

		buildUrl: function (fqdn, path, params, encodeSpaces) {
			encodeSpaces = encodeSpaces === undefined ? true : encodeSpaces;
			if (!fqdn || !path) {
				throw 'BuildPath() missing fqdn or path.';
			}
			fqdn = fqdn.replace(/\/$/, '');
			path = path.replace(/^\//, '');
			path = path.replace(/\/$/, '');
			var url = fqdn + '/' + path;
			if (params && Object.items(params).length > 0) {
					var list = [];
					for (var key in params) {
							list.push(key + '=' + params[key]);
					}
					url += '?' + list.join('&');
					url = Platform.Function.UrlEncode(url, encodeSpaces);
			}
			return url;
		}
	}

	var apiResult = {
		status: 500,
		'body': 'API request failed.'
	};
	try {
		// Get the token from the request:
		var tokenHeader = Platform.Request.GetRequestHeader('Authorization');
		if (tokenHeader) {
			var token = tokenHeader.split(' ')[1];
			// Test the token:
			mcApiTest.setup(token);
			var result = mcApiTest.request('GET', '/platform/v1/tokenContext');

			apiResult.status = result.ok ? 200 : 401;
			apiResult.body = result.body;
			if (result.ok) {
				// TODO: continue your logic here and return the result in the apiResult object:
				apiResult.body = 'Hello World!';
			}
		} else {
			// not allowed:
			apiResult.status = 401;
			apiResult.body = 'No token provided.';
		}
		Platform.Variable.SetValue('apiResult', Stringify(apiResult));
	} catch(err) {
		apiResult.body = String(err);
		Platform.Variable.SetValue('apiResult', Stringify(apiResult));
	}
</script>
%%=v(@apiResult)=%%

This method is well-suited for scenarios where server-to-server communication is involved like custom SFMC APIs. However, it may not be ideal for user logins due to potential issues with sharing of client credentials or the need for individual user Installed Packages. Additionally, it’s essential to note that while this approach handles authentication for REST API calls made behind the scenes, it may not address other security considerations such as package scopes. And it requires an additional API call (most of the time).

Web App

This approach requires users to authenticate themselves using the SFMC login and Web App API Credentials behind the scenes. Following code lets you set you handle the Web Auth within a single Cloud Page. You also will need to set up Web App Installed Package.

<script runat=server language="JavaScript">
	Platform.Load("core", "1.1.1");

	var SFMC_SETUP = {
		// only the organization part of the api path ('https://{subdomain}.auth.marketingcloud.com)
		'subdomain': '{{MC_SUBDOMAIN}}',
		'clientId': '{{WEB_APP_CLIENT_ID}}',
		'clientSecret': '{{WEB_APP_CLIENT_SECRET}}',
		'redirectUrl': '{{WEB_APP_REDIRECT_URL}}' // can be itself
	};

	// UTILITIES:
	if (!Object.items) {
		Object.items = function (obj) {
			var arr = [], key;
			for (key in obj) {
				if (obj.hasOwnProperty(key)) {
					arr.push(obj[key]);
				}
			}
			return arr;
		};
	}

	var mcWebApp = {
		credentials: {
			grant_type: 'authorization_code',
			client_id: '',
			client_secret: ''
		},

		setup: function (code) {
			this.authUrl = 'https://' + SFMC_SETUP.subdomain + '.auth.marketingcloudapis.com';
			this.restUrl = 'https://' + SFMC_SETUP.subdomain + '.rest.marketingcloudapis.com';

			this.credentials.client_id = SFMC_SETUP.clientId;
			this.credentials.client_secret = SFMC_SETUP.clientSecret;
			this.credentials.code = code;
			this.credentials.redirect_uri = SFMC_SETUP.redirectUrl;
			// get the token:
			if (code && !this.getToken(code)) {
				throw 'Marketing Cloud API token was not found.';
			}
		},

		getToken: function (code) {
			var loginUrl = this.authUrl + '/v2/token';
			var body = this.credentials;
			var result = false;

			var req = new Script.Util.HttpRequest(loginUrl);
			req.emptyContentHandling = 0;
			req.retries = 2;
			req.continueOnError = true;
			req.contentType = "application/json; charset=utf-8";
			req.method = "POST";
			req.postData = Stringify(body);

			var result = req.send();

			if (Number(result.statusCode) === 200) {
				var responseObj = Platform.Function.ParseJSON(result.content + '');
				this.token = responseObj['access_token'];
				return true;
			} else {
				throw 'API token not obtained: ' + result.statusCode + '.';
				return false;
			}
		},

		request: function (httpMethod, path, body, urlParams, useAuthUrl) {
			var result = {
				'ok': false,
				'body': 'API request ' + path + ' failed.'
			};

			try {
				var u = useAuthUrl === true ? this.authUrl : this.restUrl;
				var requestUrl = this.buildUrl(u, path, urlParams, false);

				var req = new Script.Util.HttpRequest(requestUrl);
				req.emptyContentHandling = 0;
				req.retries = 2;
				req.continueOnError = true;
				req.contentType = "application/json";
				var token = 'Bearer ' + this.token;
				req.setHeader("Authorization", token);
				req.setHeader("Accept", "application/json");
				req.method = httpMethod;

				if (body !== undefined) {
					if (typeof (body) === 'string') {
						req.postData = body;
					} else {
						req.postData = Stringify(body);
					}
				}
				
				var httpResult = req.send();
				
				var status = Number(httpResult.statusCode);
				if (status == 200 || status == 201 || status == 202 || status == 204) {
					result.ok = true;
				}
				result.body = httpResult.content + '';
				result.status = status;
			} catch (err) {
				throw "Error in request(): " + err + " - message: " + err.message;
			}
			return result;
		},

		redirectToAuth: function() {
			var url = this.authUrl + '/v2/authorize?response_type=code&client_id=' + this.credentials.client_id + '&redirect_uri=' + mcWebApp.credentials.redirect_uri;
			url = Platform.Function.UrlEncode(url);
			Platform.Response.Redirect(url);
		},

		buildUrl: function (fqdn, path, params, encodeSpaces) {
			encodeSpaces = encodeSpaces === undefined ? true : encodeSpaces;
			if (!fqdn || !path) {
				throw 'BuildPath() missing fqdn or path.';
			}
			fqdn = fqdn.replace(/\/$/, '');
			path = path.replace(/^\//, '');
			path = path.replace(/\/$/, '');
			var url = fqdn + '/' + path;
			if (params && Object.items(params).length > 0) {
					var list = [];
					for (var key in params) {
							list.push(key + '=' + params[key]);
					}
					url += '?' + list.join('&');
					url = Platform.Function.UrlEncode(url, encodeSpaces);
			}
			return url;
		}
	}

	// AUTOMATION CODE:
	try {
		var code = Platform.Request.GetQueryStringParameter('code');
		
		if (code) {
			// verify code:
			mcWebApp.setup(code);
			/* *** APP LOGIC START *** */
			var body = undefined;
			var qParams = undefined;

			var res = mcWebApp.request('GET', '/v2/userinfo', body, undefined, true);

			if (res.ok) {
				var body = Platform.Function.ParseJSON(res.body);
				if (body && body.user && body.user.name) {
					Platform.Variable.SetValue('username', body.user.name);
				} else {
					Platform.Variable.SetValue('username', 'unknown');
				}
				Platform.Variable.SetValue('allowed', true);
			} else {
				throw "Error - API call failed:" + Stringify(res);
			}
			/* *** APP LOGIC END *** */
		} else {
			mcWebApp.setup();
			mcWebApp.redirectToAuth();
		}
	} catch (err) {
		Write("An error has occurred: " + String(err));
		Platform.Variable.SetValue('allowed', false);
	}
</script>
%%[ IF @allowed == true THEN ]%%
	You are authenticated as %%=v(@username)=%%.
%%[ ELSE ]%%
	You are not authenticated.
%%[ ENDIF ]%%

This is probably the most secure way to access the pages, but the login process is even more complicated to setup. Not to forget, that the auth code (query string code) is valid for much shorter time (and for a single login). It also requires the developers to have the Web App API credentials (and have those in the Cloud Page), which is not always practical.

A big advantage of this approach is, that the developers can use the same credentials as they use for the SFMC login and they do not need to remember another set of credentials for API calls. However, each user that needs to access such a page, needs to have SFMC user.

Conclusion

Each of these methods has its own advantages and disadvantages. Overall, the biggest trade-off is between security and convenience. The more secure options require additional steps during development and testing, which can be a hassle. However, the security they provide is invaluable, especially when dealing with sensitive data.

One more thing that we need to mention is, that the developers should not forget to remove the security measures from some of the production scripts, as they are not necessary there and can cause issues with the page functionality (script activities, subscriber forms, etc.).

If you want to secure your Dev Cloud Pages without the hassle of manually implementing these security measures, you can use the SSJS Manager VSCode extension. It provides a simple and effective way to secure your pages, and it also helps with the deployment of the pages. You also do not need to worry about leaving your security scripts within Script activities.

As of v0.3.7 it already supports tokenization and login forms, and future versions will include even more options for securing your pages. Plus, your development environment will be protected with automatic security patches for Dev Pages.

Oh hi there 👋
I have a FREE e-book for you.

Sign up now to get an in-depth analysis of Adobe and Salesforce Marketing Clouds!

We don’t spam! Read our privacy policy for more info.

Share With Others

Leave a Comment

Your email address will not be published. Required fields are marked *

SFMC Developer

Filip Boštík

Filip Boštík

Salesforce Developer with over 6 years of hands-on experience. From crafting innovative solutions for diverse industries and companies to optimizing Salesforce Marketing Cloud. Also the creator of two VSCode extensions for SFMC: AMPscript Beautifier and SSJS Manager.

Buy me a coffee