This article outlines an approach for integrating CrowdHandler with a single page application (SPA) to protect it against excessive traffic and maintain a smooth user experience. The integration consists of two main components:
1.) CrowdHandler's Javascript integration with SPA mode enabled.
2.) A custom server-side integration protecting the API (or APIs) that power your SPA.
The role of the Javascript integration is to work as the first and primary layer of protection, responsible for checking user requests, managing promotion state in-browser, and redirecting users to the waiting room if necessary.
The role of the Server-Side integration is to act as a second layer of protection, guarding against anyone savvy enough to bypass the Javascript integration as well as being responsible for feeding CrowdHandler performance information.
Installing the Javascript Integration
The first step is to install our Javascript integration with SPA mode enabled
By default, CrowdHandler checks will only take place on a full DOM reload i.e. when the browser is hard refreshed or when a page is first fetched from your web server prior to the application bundle being downloaded. In SPA applications this has the effect of users "going dark" to CrowdHandler after their initial hit.
SPA mode solves this problem by triggering additional functionality that results in CrowdHandler checks being performed whenever the URL changes, regardless of whether a DOM reload occurred or not. This is achieved by tracking the URL state and using an event listener to force a CrowdHandler check whenever a change is detected.
Protecting your API(s)
The specific way that you protect your API with CrowdHandler is dependent on the language/framework that you use making it impossible to cover all scenarios in this guide. Some specific implementation examples for NodeJS and Lambda@Edge (CloudFront) environments are linked toward the end of the article.
1.) Add additional fields to your API request payloads.
For the purpose of this example, let's say that you are managing an e-commerce SPA. There is a single API, under your control that is being called to fetch data.
We are going to presume the following, but our examples can easily be adapted to meet your needs:
- Payloads are being submitted using content type application/json.
- You are only interested in protecting PUT & POST methods. This typically covers operations such as add to basket and checkout which is sufficient to prevent Javascript integration bypassers from being able to complete end to end journies. *
* There is nothing stopping you from protecting all API calls/request method types and this may be appropriate if you are concerned about bad actors targeting load intensive API routes that respond to GET methods for example. You will need to add and extract the additional fields as query string parameters.
Fields
Key: sourceURL
Value: location.href (or equivalent)
Key: chToken
Value: local storage crowdhandler token *
* Here is a simple example function that extracts the CrowdHandler token from local storage. Replace my.domain.com with your site domain and submit empty strings "" if no token is found. This is important as it informs the server-side code that a fresh CrowdHandler>session should be assigned.
//Storage format '{"countdown":{},"positions":{},"token":{"my.domain.com":"tok0N53DjDMpWeid"}}'
try { let ch_storage = JSON.parse(localStorage.getItem("crowdhandler")) return ch_storage.token["my.domain.com] } catch (error) { return "" }
2.) Install Server-Side code
The purpose of the server-side code is to sit in front of your API and validate requests against CrowdHandler for promotion state. API calls that do not present a promoted CrowdHandler session should be stopped in their tracks.
The sourceURL value that you provided in your API payloads is used as the temporary URL when checking in with CrowdHandler. In the control panel, you will have configured CrowdHandler to protect your website URLs, not your API URLs. This temporary rewrite using the sourceURL value informs CrowdHandler of the page that the API call originated from.
The CrowdHandler token is extracted from the chToken value that you provided in your API payloads.
See code comments for more implementation details.
Example - Express Framework
const express = require("express"); const router = express.Router(); const crowdhandler = require("crowdhandler-sdk"); const { URL } = require("url"); // Middleware to handle CrowdHandler logic for POST and PUT methods const crowdHandlerMiddleware = async (req, res, next) => { const method = req.method; // Check if the request method is POST or PUT if (method === "POST" || method === "PUT") { const publicKey = "YOUR_PUBLIC_KEY"; const public_client = new crowdhandler.PublicClient(publicKey); const ch_context = new crowdhandler.RequestContext(req, res); const ch_gatekeeper = new crowdhandler.Gatekeeper( public_client, ch_context, publicKey ); let decodedBody; let chToken; let sourceURL; if (req.body) { try { decodedBody = JSON.parse(req.body); chToken = decodedBody.chToken; sourceURL = decodedBody.sourceURL; // Extract host & path from sourceURL let url = new URL(sourceURL); let temporaryHost = url.host; let temporaryPath = url.pathname; // Override the gatekeeper host and path with the sourceURL ch_gatekeeper.overrideHost(temporaryHost); ch_gatekeeper.overridePath(temporaryPath); // If there's a token in the body, provide gatekeeper with a pseudo cookie if (chToken) { ch_gatekeeper.overrideCookie(`crowdhandler=${chToken}`); } } catch (error) { console.error("Error parsing JSON:", error); return next(error); } } const ch_status = await ch_gatekeeper.validateRequest(); // If the request is not promoted, send a 403 Forbidden response and do not proceed to the next middleware if (!ch_status.promoted) { res.status(403).send("Forbidden"); return; } else { // If the request is promoted, save the ch_gatekeeper instance in res.locals for later use res.locals.ch_gatekeeper = ch_gatekeeper; } } // Continue to the next middleware or route handler next(); }; // Add the CrowdHandler middleware to the router router.use(crowdHandlerMiddleware); // Route handler for all request methods and paths router.all("*", (req, res, next) => { // Render the view and send the HTML res.render("index", { title: "hello" }, (err, html) => { // Handle any errors during rendering if (err) { return next(err); } // Send the rendered HTML to the client res.send(html); // If the ch_gatekeeper instance exists in res.locals, record the performance if (res.locals.ch_gatekeeper) { res.locals.ch_gatekeeper.recordPerformance(); } }); }); // Export the router module.exports = router;
Example - Lambda@Edge
Viewer Request
"use strict"; //include crowdhandler-sdk const crowdhandler = require("crowdhandler-sdk"); const publicKey = "YOUR_PUBLIC_KEY_HERE"; let ch_client = new crowdhandler.PublicClient(publicKey, { timeout: 2000 }); module.exports.viewerRequest = async (event) => { //extract the request from the event let request = event.Records[0].cf.request; let decodedBody; let chToken; let sourceURL; //if the request is not a POST or PUT request, return the request unmodified if (request.method !== "POST" || request.method !== "PUT" ) { return request; } if (request.body && request.body.encoding === "base64") { // Decode the base64 encoded body decodedBody = Buffer.from(request.body.data, "base64").toString("utf8"); // Parse the JSON encoded body try { // Parse the decoded body into a JSON object decodedBody = JSON.parse(decodedBody); //destructure sourceURL, chToken from the decoded body chToken = decodedBody.chToken; sourceURL = decodedBody.sourceURL; // Now you can work with the JSON object } catch (error) { console.error("Error parsing JSON:", error); // Handle the error or return the request object unmodified return request; } } //extract host & path from sourceURL using URL API let url = new URL(sourceURL); let temporaryHost = url.host; let temporaryPath = url.pathname; //Filter the event through the Request Context class let ch_context = new crowdhandler.RequestContext({ lambdaEvent: event }); //Instantiate the Gatekeeper class let ch_gatekeeper = new crowdhandler.Gatekeeper( ch_client, ch_context, { publicKey: publicKey, }, { debug: true } ); //Override the gatekeeper host with the sourceURL ch_gatekeeper.overrideHost(temporaryHost); //Override the gatekeeper path with the sourceURL ch_gatekeeper.overridePath(temporaryPath); //If there's a token in the body provide gatekeeper with a pseudo cookie so that it can check that the provided token is valid/promoted if (chToken) { ch_gatekeeper.overrideCookie(`crowdhandler=${chToken}`); } //Validate the request let ch_status = await ch_gatekeeper.validateRequest(); //If the request is not promoted, reject the request if (!ch_status.promoted) { return { status: "403", statusDescription: "Forbidden", headers: { "content-type": [ { key: "Content-Type", value: "text/plain", }, ], "cache-control": [ { key: "Cache-Control", value: "max-age=0", }, ], }, body: "Access to this resource is forbidden.", }; } //If the request is promoted, allow it to proceed normally //set customer headers for recording performance on the request before passing it through request.headers["x-crowdhandler-responseID"] = [ { key: "x-crowdhandler-responseID", value: `${ch_status.responseID}` }, ]; request.headers["x-crowdhandler-startTime"] = [ { key: "x-crowdhandler-startTime", value: `${Date.now()}` }, ]; //return the request return request; };
Origin Response
const crowdhandler = require("crowdhandler-sdk"); const publicKey = "YOUR_PUBLIC_KEY_HERE"; let ch_client = new crowdhandler.PublicClient(publicKey, { timeout: 2000 }); module.exports.originResponse = async (event) => { let request = event.Records[0].cf.request; let requestHeaders = event.Records[0].cf.request.headers; let response = event.Records[0].cf.response; let responseStatus = response.status; //convert response status to number responseStatus = parseInt(responseStatus); //extract the custom headers that we passed through from the viewerRequest event let responseID; let startTime; try { responseID = requestHeaders["x-crowdhandler-responseid"][0].value; } catch (e) {} try { startTime = requestHeaders["x-crowdhandler-starttime"][0].value; } catch (e) {} //Work out how long we spent processing at the origin let elapsed = Date.now() - startTime; let ch_context = new crowdhandler.RequestContext({ lambdaEvent: event }); //Instantiate the Gatekeeper class let ch_gatekeeper = new crowdhandler.Gatekeeper( ch_client, ch_context, { publicKey: publicKey, }, { debug: true } ); //If we don't have a responseID or a startTime, we can't record the performance if (!responseID || !startTime) { return response; } //This is a throw away request. We don't need to wait for a response. await ch_gatekeeper.recordPerformance({ overrideElapsed: elapsed, responseID: responseID, sample: 1, statusCode: responseStatus, }); //Fin return response; };
3.) Taking things further...
The examples above are relatively simple solutions to blocking traffic to your API for users that are considered unauthorized by CrowdHandler.
If you want to play nice with users that are accessing your APIs directly or are concerned about edge cases, you could alter the example code to return a JSON response containing a fully formed waiting room URL. Consult our JS SDK documentation to see how you would get hold of this URL.
With the full, waiting room URL to hand, you could surface it in the response and have your client-side code rewrite the current URL to the waiting room URL.
Remember! This has to be done client-side, rewriting the API requests server-side is essentially the same as returning a 403 response and will redirect the API calls, not the user's browser.
4.) Final notes
While we hope that the examples provided are clear and useful, we understand that sometimes you need to speak to a specialist for advice and clarification. Our integration experts are available via [email protected] and ready to assist where needed.