Secure CloudPages Login with SSJS

If you’ve ever needed to gate a CloudPage behind a login — for an internal tool, a partner portal, or any page that shouldn’t be publicly indexable — you’ve probably discovered that Marketing Cloud doesn’t give you this out of the box. There’s no built-in auth layer for CloudPages, so you end up rolling your own with Server-Side JavaScript (SSJS), Data Extensions, and cookies.

This post walks through a working pattern for exactly that: a login page, a session gate you can drop at the top of any protected page, a password-update flow, and a logout page. Along the way I’ll cover a few things that are easy to get wrong the first time — validating sessions correctly, restricting requests to your own domain, and, most importantly, how you represent a session in the first place.

The moving pieces

The whole system rests on five CloudPages and two Data Extensions:

  • Login page — collects email/password, checks them against a Data Extension, and issues a session.
  • A session gate — a block of SSJS you paste at the top of every page that should require login. It validates the session and either lets the request through or bounces it back to the login page.
  • Update password page — lets a logged-in user change their password.
  • Logout page — ends the session.
  • Auth_UserProfile’ Data Extension — stores Email, Username, and Password (hashed) for each user.
  • Auth_SessionTokens’ Data Extension — stores active session tokens (more on why this exists below).

Auth_UserProfile structure, matching that format:

FieldTypeLengthPrimary Key
EmailText254Yes
UsernameText100No
PasswordText100No

A few notes on the choices:

  • Email as the primary key rather than Username — every lookup in the scripts (Platform.Function.Lookup(‘ENT.BAU_User’,’Username’,’Email’,email), etc.) filters by email, so it’s the natural unique identifier. Make sure it’s set to reject duplicates.
  • Password at length 100 — that’s generous headroom for a SHA256 hex digest (64 characters) as used in the current scripts, with room to spare if you ever move to a longer hash/salt combination later.
  • If you want Username to also be guaranteed unique (not just Email), you’d need a second primary key or a unique index on that field — SFMC Data Extensions support composite primary keys, but the scripts as written don’t require it since they never look anything up by Username alone.

Hashing inside SSJS

SFMC doesn’t expose a native SHA256 function to SSJS directly, but you can reach it through AMPscript by wrapping a snippet in TreatAsContent:

function encrypt(str) {
  Variable.SetValue("@ToEncrypt", str);
  var scr = "";
  scr += "\%\%[";
  scr += "SET @Encrypted = SHA256(@ToEncrypt)";
  scr += "Output(Concat(@Encrypted))";
  scr += "]\%\%";
  return Platform.Function.TreatAsContent(scr);
}Code language: PHP (php)

It’s a bit of a workaround — set an AMPscript variable, run an inline AMPscript block that hashes it, and pull the result back out as a string — but it works reliably and it’s the pattern most SFMC devs land on for this exact problem. This function is used once, at login and at password update, purely to compare/store password hashes against Auth_UserProfile.

What not to put in the session cookie

The first version of this I put together set three cookies at login: username, email, and a session cookie computed as encrypt(username) + userpass — i.e. SHA256(username) concatenated with the user’s stored password hash. Every protected page recomputed that same value from the Data Extension and compared it to the cookie.

It’s a tempting shortcut because it needs no extra storage — but it’s worth being explicit about what that cookie actually contains: a value derived entirely from static, permanent user data. In practice that means:

  • It’s a bearer token with no real lifecycle. It’s valid on any device, from any IP, until the password changes — there’s no way to revoke a single session without forcing a password reset.
  • It leaks hash material. The password hash is now sitting in the client’s cookie store as well as the Data Extension — one more place it can leak from.
  • SHA256 is fast, which is exactly the wrong property for anything password-adjacent — if that hash ever leaks, it’s crackable at billions of guesses per second on commodity hardware.
  • It isn’t actually a session. A session is supposed to represent “this device is currently logged in, and I can end that independently of anything else.” A recomputable hash can’t do that.

So it’s not a great default, and it’s worth swapping out before this pattern goes anywhere near real user data. Here’s the fix.

The better approach: random, opaque, revocable tokens

Instead of deriving a “session” from permanent data, generate a random token at login, store it server-side with an expiry, and hand the token — nothing else — to the browser as the cookie. That requires one more Data Extension:

Auth_SessionTokens

FieldTypeLengthPrimary Key
TokenText100Yes
EmailText254No
UsernameText100No
ExpiresAtDateNo


Login now looks like this:

  if (email && password) {
    checkReferrer();

    var username = Platform.Function.Lookup('Auth_UserProfile','Username','Email',email);
    var userpass = Platform.Function.Lookup('Auth_UserProfile','Password','Email',email);

    if (userpass != null && userpass != "" && userpass == encrypt(password)) {

      // Issue a random, opaque session token instead of a hash derived from
      // the password. This is the only thing that goes in the cookie now —
      // it carries no information about the password hash.
      var token = Platform.Function.GUID();
      var expireStr = "\%\%=FormatDate(DateAdd(Now(1), " + sessionMinutes + ", \"MI\"), \"MM/dd/yyyy HH:mm:ss\")=\%\%";
      var expiresAt = TreatAsContent(expireStr);

      Platform.Function.UpsertData(
        "Auth_SessionTokens",
        ["Email"], // Look for this Primary Key
        [email],
        ["Token", "Username", "ExpiresAt"], // Update or insert these fields
        [token, username, expiresAt]
      );

      Platform.Response.SetCookie("sessionToken", token);
      Platform.Response.Redirect(landingPageURL);

    } else {
      Platform.Variable.SetValue("@error", "Invalid username or password");
    }Code language: PHP (php)

The session gate

The session gate is a lookup:

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

var sessionMinutes = 60; // must match the value used at login
var loginPageURL = Variable.GetValue("@loginPageURL");

function minutesPastExpiryFn(expiresAtDate) {
  Variable.SetValue("@ExpiresAt", expiresAtDate);
  var scr = "";
  scr += "\%\%[";
  scr += "SET @Result = DateDiff(@ExpiresAt, Now(1), \"MI\")";
  scr += "Output(Concat(@Result))";
  scr += "]\%\%";
  return Platform.Function.TreatAsContent(scr);
}

var token = Platform.Request.GetCookieValue("sessionToken");
var username = null;
var email = null;

try {
  if (!token || token == "") {
    Platform.Response.Redirect(loginPageURL);
  } else {
    var rows = Platform.Function.LookupRows("Auth_SessionTokens", "Token", token);

    if (!rows || rows.length == 0) {
      // Unknown token — either never existed or was already logged out
       Platform.Response.Redirect(loginPageURL);     
    } else {
      var row = rows[0];
      var expiresAt = row["ExpiresAt"];
      var minutesPastExpiry = minutesPastExpiryFn(expiresAt);
      
      if (minutesPastExpiry >= 0) {
        // Expired — clean up the row and send them back to login
        DataExtension.Init("Auth_SessionTokens").Rows.Remove(["Token"], [token]);
        Platform.Response.SetCookie("session", "");
        Platform.Response.Redirect(loginPageURL);
      } else {
        username = row["Username"];
        email = row["Email"];
        Variable.SetValue("@username", username);
        
        var expireStr = "\%\%=FormatDate(DateAdd(Now(1), " + sessionMinutes + ", \"MI\"), \"MM/dd/yyyy HH:mm:ss\")=\%\%";
        var newExpiresAt = TreatAsContent(expireStr);
   
        Platform.Function.UpsertData(
          "Auth_SessionTokens",
          ["Email"], // Look for this Primary Key
          [email],
          ["ExpiresAt"], // Update or insert these fields
          [newExpiresAt]
        );

        Platform.Response.SetCookie("sessionToken", token);
      }
    }
  }
} catch(e) {
  Write(Stringify(e));
}

</script>Code language: PHP (php)

Notice every failure path is explicit: missing cookie, unknown token, expired token. This matters more than it might look — an earlier version of the session check (the derived-hash version, written slightly differently) had a condition where a request arriving with no cookies at all could slip through undetected, because of how loose equality treats null vs. undefined in SSJS. Writing out each failure case by name, rather than relying on a compound boolean to catch every edge case implicitly, avoids that class of bug entirely.

Logout becomes a real revocation

This is where the token approach earns its keep. Logout no longer just clears a cookie client-side — it deletes the session row, so the token is dead immediately, everywhere, even if a copy of the cookie exists somewhere else (another browser, a proxy log, a compromised device):

var token = Platform.Request.GetCookieValue("session");

if (token && token != "") {
  DataExtension.Init("Auth_SessionTokens").Rows.Remove(["Token"], [token]);
  Platform.Response.SetCookie("session", "");
}Code language: JavaScript (javascript)

Update password no longer needs to touch the session

Because the session token isn’t derived from the password hash, changing the password doesn’t automatically invalidate the token the user is currently using — which is a nice side effect, since it means the update flow doesn’t need to re-issue a cookie at all. If you want a password change to log the user out on every other device (a reasonable security practice if a device is lost or a password may have been compromised), that’s a few extra lines: look up every session row for that email, and remove all of them except the current token.

Restricting requests to your own domain

Independent of how sessions are represented, it’s still worth restricting form submissions to requests that actually originated from your own domain:

var loginPageURL = Variable.GetValue("@loginPageURL");
  
function checkReferrer() {
  var allowedDomain = Platform.Request.GetRequestHeader("Host");
  var referrer = Platform.Request.GetRequestHeader("Referer");
  if (referrer == null || referrer.indexOf(allowedDomain) == -1) {
    Platform.Response.Redirect(loginPageURL);
  }
}Code language: JavaScript (javascript)

Worth repeating: the Referer header is client-supplied and sometimes absent under strict referrer policies, so treat this as a filter for casual direct-linking and stray traffic — not as your actual authentication boundary. That job belongs entirely to the session check.

Putting it together

  1. A visitor submits credentials on the login page.
  2. On success, the login page generates a random token, stores it in Auth_SessionTokens with an expiry, and sets it as the session cookie.
  3. Every protected page starts with the session gate, which looks the token up, checks it hasn’t expired, and redirects to login if either check fails.
  4. The update-password page validates the current session the same way, updates the password hash in Auth_UserProfile, and — optionally — revokes every other active session for that user.
  5. Logout deletes the session row server-side and clears the cookie.

If you are interesting in testing how it works, here is the package file for deploying the cloudpages and the data extensions on SFMC Package Manager. The source code for the cloudpages can be downloaded here.

Install the package file

  1. Download the package file.
  2. Upload the JSON file and install the package under Deployment tab on SFMC Package Manager.
  3. Go to Cloudage Pages and locate the pages under folder “Password Protected Cloudpages”.
  4. Activate/Publish pages.
  5. Add user on page “Add User”.
  6. Unpublish “Add User” page.
  7. Login from the “Login page” with the new user login details.

Similar Posts