Build a Jamstack Form with Serverless Functions and a Stateless CSRF Token

Main Content

Author: James Edwards Published: 1/16/2021 Updated: 6/21/2021

To mitigate Cross-site request forgery attacks, websites that submit forms can include a nonce, to make sure that the request is being sent from the origin that is expected. This way, a post request containing the nonce, or public token, can be verified with a secret, and stored on the server before mutating any data. Using a CSRF token doesn't guarantee that a website will be safe from malicious requests, however it can help prevent malicious requests, or requests generated by automated bots.

This example will show how a publicly available HTML form, can be submitted using the Fetch API, with TypeScript, to first asynchronously retrieve a valid token, and then submit that token in a second request to save the form information. For the server-side components, Azure Functions will be used, however these techniques can be applied to other server-side technologies, including a typical server.

HTML Form

We can create a form containing any fields we would like to submit. Let's create a sample contact form with some standard information to collect. There is one extra field at the bottom of the form that is hidden to act as a decoy field for bots to incorrectly submit. This can be ignored for now, but it will be validated in the serverless function handling submissions of the contact form.

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <title>Contact Form</title>
  </head>
  <body>
    <form
      id="contactForm"
      action="YOUR-DOMAIN/api"
      method="post"
      data-type="contact"
    >
      <div>
        <label for="firstName">first name</label>
        <input
          required
          type="text"
          id="firstName"
          name="firstName"
          autocomplete="given-name"
        />
      </div>
      <div>
        <label for="lastName">last name</label>
        <input
          required
          type="text"
          id="lastName"
          name="lastName"
          autocomplete="family-name"
        />
      </div>
      <div>
        <label for="email">email</label>
        <input
          required
          type="email"
          id="email"
          name="email"
          autocomplete="email"
        />
      </div>
      <div>
        <label for="website">website</label>
        <input type="text" id="website" name="website" autocomplete="url" />
      </div>
      <div>
        <label for="message">message</label>
        <textarea required rows="5" id="message" name="message"></textarea>
      </div>
      <button type="submit">Submit</button>
      <div style="position: absolute; left: -5000px" aria-hidden="true">
        <input
          id="password"
          type="text"
          name="password"
          tabindex="-1"
          value=""
          autocomplete="off"
        />
      </div>
    </form>
    <div id="form-submit-msg"></div>
    <script src="form.js"></script>
  </body>
</html>

Make sure to replace "YOUR-DOMAIN" in the form action attribute to the domain you are using. For Azure functions local development the form action could be http://localhost:7071/api. We want the form action to end with "/api", rather than include the full url, so that the form "data-type" attribute can be appended to the url later with JavaScript. This way if anyone is attempting to scrape this form they would not get the full url without inspecting the JavaScript code executing the AJAX request.

The bottom of the HTML document includes a reference to a script named "form.js" and this is where the JavaScript code to submit the form will be included. We can create that file now with TypeScript.

TypeScript Form Submit

For this example we'll use TypeScript, which will transpile to the script referenced in the HTML form (script.js). More info on how to use TypeScript with HTML forms can be found in this article showing how to submit a FormData object using the ES6 Fetch Web API. With TypeScript properly configured, we can create the form.ts file and add some of the needed code:

window.addEventListener("load", async function () {
  new FormHandler();
});

Now we can create the FormHandler class that is instantiated when the HTML document is loaded, by adding it directly below the window event listener.

class FormHandler {
  constructor() {
    this.formSubmitListener();
  }

  private formSubmitListener() {
    document.body.addEventListener("submit", async function (event) {
      event.preventDefault();
    });
  }
}

The private method "formSubmitListener" is invoked during the constructor of the FormHandler class, and includes the registration of an additional event listener that will be activated on the HTML form submit event. Currently this only prevents the default event from occurring, so we can add additional code to get the data from the form.

// inform user form is submitting
const submitButton = document.querySelector(
  "button[type=submit]"
) as HTMLInputElement;

submitButton.disabled = true;

const statusMsgElement = document.getElementById("form-submit-msg");

statusMsgElement!.innerText = "Submitting reply... Please wait.";

// gather form element data
const form = event.target as HTMLFormElement;

const formData = new FormData(form);

The first bit of code added, will select the submit button of the form and disable it during the submission so that the form cannot be submitted multiple times. Then the "form-submit-msg" element will show a message that indicates to the viewer the form is processing. After alerting the user, the form is gathered from the event target passed as an argument of the submit event listener. The "event.target" value is cast to an HTMLFormElement so that TypeScript will permit the access of the "target" property. Then a FormData object is instantiated with the form element. Next we can send the formData variable using the Fetch API.

Get csrf Token and Post FormData with Fetch API

Before accessing the result of the form submission, two extra helper functions are created to handle and log any errors that may occur during the Fetch API post request. Once the helper functions are created the Fetch request is stored in the "result" variable.

const errorHandler = async (response: Response) => {
  if (!response.ok) {
    const err = await response.json().then((err) => err);

    throw Error(
      JSON.stringify({
        status: response.status,
        statusText: response.statusText,
        error: err,
      })
    );
  }

  return response;
};

const errorLogger = (error: Error) => {
  // overwrite message to inform user
  error.message = "An error occurred. Please try again.";
  return error;
};

// submit formData with error handling and logging
const result = await fetch(
  `${form.action}/formToken/${new Date(new Date().toUTCString()).getTime()}/${
    form.dataset.type
  }`
)
  .then(errorHandler)
  .then((response: Response) => response.json())
  .then((data) => {
    // anti-forgery
    formData.append("_csrf", data.token);
    return data.type;
  })
  .then(
    async (type) =>
      // casting to any here to satisfy tsc
      // sending body as x-www-form-url-encoded
      // formData convert to array for edge browser support
      await fetch(`${form.action}/${type}`, {
        method: form.method,
        body: new URLSearchParams([...(formData as any)]),
      })
  )
  .then(errorHandler)
  .then((response: Response) => response.json())
  .then((json) => json)
  .catch(errorLogger);

statusMsgElement!.innerText = result.message;
submitButton.disabled = false;

Since we need a CSRF token and the HTML form isn't rendered server-side (it is pre-rendered as is the case with a site built with the Jamstack) there is actually two fetch requests sent. The first one is a GET request to an endpoint that will provide the token, and then that token is appended to the formData object created earlier. The url pattern for this endpoint includes the "data-type" attribute from the form and a current timestamp. The timestamp is an extra step of validation that will occur in the serverless function created later. Additionally the formToken endpoint sends back the form data type sent to it, so that it can be passed to the second request.

After getting a valid token the next request is a POST request to the form "data-type" endpoint, and the body of the request includes the updated formData object with the "_csrf" token appended. This request is responsible for saving the data if it is sent with a valid CSRF token, and the form data is valid.

The last bit of code below the result is showing a message to the user after the Fetch request completes, showing whether the submission was successful, or an error occurred and they should try again. Additionally the submit button is no longer disabled so the form can be submitted again.

The entire form.ts file should look like this:

window.addEventListener("load", async function () {
  new FormHandler();
});

class FormHandler {
  constructor() {
    this.formSubmitListener();
  }

  private formSubmitListener() {
    document.body.addEventListener("submit", async function (event) {
      event.preventDefault();

      // inform user form is submitting
      const submitButton = document.querySelector(
        "button[type=submit]"
      ) as HTMLInputElement;

      submitButton.disabled = true;

      const statusMsgElement = document.getElementById("form-submit-msg");

      statusMsgElement!.innerText = "Submitting reply... Please wait.";

      // gather form element data
      const form = event.target as HTMLFormElement;

      const formData = new FormData(form);

      const errorHandler = async (response: Response) => {
        if (!response.ok) {
          const err = await response.json().then((err) => err);

          throw Error(
            JSON.stringify({
              status: response.status,
              statusText: response.statusText,
              error: err,
            })
          );
        }

        return response;
      };

      const errorLogger = (error: Error) => {
        // overwrite message to inform user
        error.message = "An error occurred. Please try again.";
        return error;
      };

      // submit formData with error handling and logging
      const result = await fetch(
        `${form.action}/formToken/${new Date(
          new Date().toUTCString()
        ).getTime()}/${form.dataset.type}`
      )
        .then(errorHandler)
        .then((response: Response) => response.json())
        .then((data) => {
          // anti-forgery
          formData.append("_csrf", data.token);
          return data.type;
        })
        .then(
          async (type) =>
            // casting to any here to satisfy tsc
            // sending body as x-www-form-url-encoded
            // formData convert to array for edge browser support
            await fetch(`${form.action}/${type}`, {
              method: form.method,
              body: new URLSearchParams([...(formData as any)]),
            })
        )
        .then(errorHandler)
        .then((response: Response) => response.json())
        .then((json) => json)
        .catch(errorLogger);

      statusMsgElement!.innerText = result.message;
      submitButton.disabled = false;
    });
  }
}

CSRF Token Serverless Function

The client-side code is now set up, so we can look at creating the Azure TypeScript Serverless Functions that will provide a server-side environment to generate the CSRF token and then validate the token to save the form submission data. Here is the quickstart documentation for creating an Azure TypeScript function with Visual Studio code. Once that is setup, we are going to create two functions. The first is the formToken endpoint.

In your functions package.json make sure to include the csrf npm package by running the command npm install csrf --save

Here is the functions.json file associated with the index.ts formToken code that follows:

{
  "bindings": [
    {
      "authLevel": "anonymous",
      "type": "httpTrigger",
      "direction": "in",
      "name": "req",
      "methods": ["get"],
      "route": "formToken/{timeStamp:long}/{formType:alpha}"
    },
    {
      "type": "http",
      "direction": "out",
      "name": "res"
    }
  ],
  "scriptFile": "../dist/formToken/index.js"
}

This function only accepts GET requests, and requires two route parameters, timeStamp and formType. These are included in the client side script we created earlier.

Here is the formToken function code:

import { AzureFunction, Context } from "@azure/functions";
import * as csrf from "csrf";

const httpTrigger: AzureFunction = async function (
  context: Context
): Promise<void> {
  context.log("HTTP trigger function processed a request.");

  context.res!.headers["Content-Type"] = "application/json";

  const utcTime = new Date().toUTCString();

  const submitTime = new Date(
    new Date(context.bindingData.timeStamp).toUTCString()
  ).getTime();

  // add some skew
  const futureDateLimit = new Date(utcTime).getTime() + 1000 * 60 * 5;

  const pastDateLimit = new Date(utcTime).getTime() - 1000 * 60 * 5;

  if (submitTime > futureDateLimit || submitTime < pastDateLimit) {
    // don't create token but also don't return error
    context.res!.status = 200;
    context.res!.body = { message: "success" };
  } else {
    const tokens = new csrf();

    const token = tokens.create(process.env["csrfSecret"]);

    context.res!.status = 200;
    context.res!.body = { token: token, type: context.bindingData.formType };
  }
};

export default httpTrigger;

This function first gathers the current time, and then the time submitted as the timeStamp route parameter. Then a past and future date limit are calculated based on the current time. If the submitted timeStamp is not within the date limit range then the request is ignored and a fake success message is sent back. This is to deter any bots from attempting to make anymore additional requests.

If the timestamp is valid, a new token is generated using the csrf npm package tokens.create() function. In order to prevent the secret from being accessed publicly or accidentally stored in a git repository, a process environment variable is referenced to obtain the "csrfSecret" value. This is the documentation on how to add an application setting in the Azure portal. With the generated token, the function returns the response object, including the token and the "formType" route parameter that was sent with the request.

In this example the same secret is used for all tokens that are generated. This may be useful as all tokens can be invalidated by changing the secret, and given the short length of the token date limit range this can work well. However, it may be advantageous to use the csrf npm package token.secret() function to dynamically create a new secret for each token that is generated. You could then store both the token and the secret in a database, or Azure Table Storage, and use the token to look up the stored secret, to later verify the token on the subsequent request.

Contact Form Serverless Function

The second serverless function is going to accept the contact form data with the csrf token appended. Additionally, it will verify the hidden decoy password form field, and the csrf token. If both validations pass then the data can be saved.

Here is the functions.json for the contact serverless function:

{
  "bindings": [
    {
      "authLevel": "anonymous",
      "type": "httpTrigger",
      "direction": "in",
      "name": "req",
      "methods": ["post"]
    },
    {
      "type": "http",
      "direction": "out",
      "name": "res"
    }
  ],
  "scriptFile": "../dist/contact/index.js"
}

Note that the contact function is limited to only accept post requests.

Below is the index.ts function code:

import { AzureFunction, Context, HttpRequest } from "@azure/functions";
import * as querystring from "querystring";
import * as csrf from "csrf";

const httpTrigger: AzureFunction = async function (
  context: Context
): Promise<void> {
  context.log("HTTP trigger function processed a request.");

  context.res!.headers["Content-Type"] = "application/json";

  //sent as x-www-form-url-encoded
  const body = querystring.parse(req.body);

  // check hidden form field
  const verifiedHiddenFormField =
    body && (body.password === undefined || body.password.length);

  // verify token with secret
  const verifiedToken = new csrf().verify(
    process.env["csrfSecret"],
    body._csrf
  );

  if (!verifiedHiddenFormField || !verifiedToken) {
    // failed verification
    context.res!.status = 200;
    context.res!.body = { message: "success" };
    return;
  }

  if (
    !(body && body.firstName && body.lastName && body.email && body.message)
  ) {
    context.res!.status = 400;
    context.res!.body = {
      message: "Contact form is invalid. Please correct errors and try again.",
    };
    return;
  }

  //todo: save the comment form data!

  context.res!.status = 200;
  context.res!.body = {
    message: "Thank you for contacting me! I will reply to you shortly.",
  };
};

export default httpTrigger;

The contact function first parses the request body using the querystring parse method, which will create an object from the form data that was sent. Then the decoy password field is verified to exist, but also not have a value present. The csrf token appended to the form data is then verified using the process.env "csrfSecret" value. If both of these verifications are passing, then the function execution can proceed. Otherwise, like the formToken function, an empty success message is returned to deter further, possibly malicious requests.

After verification, the contact form info is checked to make sure all the fields have a value. If they don't an error message is returned and displayed to the viewer with the client side errorHandler and errorLogger functions created earlier.

At this point, with both verifications passing and valid form data, the data can be saved to the preferred data store. This could be a sql database or a nosql data store like azure storage. Once the save is complete the function will return a success message and the client side code will display that to the viewer.

Edit this post on GitHub