How to Secure API Routes for Jamstack Sites

On the landing page of our website, https://stepzen.com, you see an example of a query that can be used to provide a weather forecast for a visitor to your website, using their IP address as basis for the location.

Localized Weather Query

StepZen makes building the API backend to support a query like this easy by allowing you to stitch together data coming from multiple external APIs. In this article, we'll look at how you can use a query like this to personalize a site built with the Jamstack.

Putting it on the Web

If you're using server-side rendering for your website, you're all set. You can just call this API when you render requests and viola, you're done.

But what if we want to do this on a Jamstack website? The majority of a Jamstack site is pre-rendered at build time via an SSG, but personalized content like this would need to be retrieved and rendered on the client-side using API calls from the browser.

Today, making API calls from the browser is pretty easy. The JavaScript fetch API makes asynchronous API calls in the browser straightforward, but there are some things we should be concerned about:

  • If the API I'm calling requires authentication, that authentication would be visible to any user visiting the page that makes the API call. I don't want to share my API keys with the world, so I'll need to figure out how to hide those.
  • Depending on the API, I may want to restrict what users can do with it. In our example, I don't want users to be able to retrieve the weather for any location, I only want them to see the current conditions for their IP address. I pay for access to the API, and I don't want people to reverse engineer my website in order to reuse the API on my dime.

Browser API Call

Today, one of the best ways to accomplish both these goals is to use a serverless function, such as AWS Lambda, GCP Cloud Run or Netlify Functions, to make the backend API call. We're creating a simple "wrapper" API that our web app can call, which takes care of authentication to the backend API and limiting what can be done with the API. Curious users will be able to determine how to call our serverless function, but that's all they will be able to do.

Browser Serverless Function Call

This is such a common pattern that some providers and frameworks have built automation to make this very easy. In the next section we'll look at a live example.

Building the Site

Next.js is a React framework with support for static and server rendering, which makes building a Jamstack site with dynamic content easy. Additionally, Next.js has a feature called API Routes, which makes building an API straightforward. API Routes are treated differently than pages, are server-side only. We will use API Routes to define the code for our serverless function.

Netlify is a hosting platform that makes it simple to host static sites, but they also provide serverless functions via Netlify Functions. On top of that, they have a plugin for Next.js sites that automatically creates serverless functions for any of the Next.js /api pages.

We will build our weather example using Next.js, creating an API call to retrieve the weather for the visitor's IP address. We'll then host the site on Netlify, and have Netlify automatically turn our API into a serverless function. Then we'll inspect the client-side code, and see how we've hidden our API key and restricted the capability of the API.

Our code is here: https://github.com/carloseberhardt/weather-test

Let's look at the structure of that app quickly, and point out a few key items.

.
├── components
│   └── weather.js
├── pages
│   ├── api
│   │   └── weather.js
│   ├── _app.js
│   └── index.js

The first key part of our web app is a React component defined in components/weather.js that retrieves and displays the forecast:

import fetch from 'unfetch'
import useSWR from 'swr'

const fetcher = url => fetch(url).then(r => r.json())

export default function Weather() {
    const { data, error } = useSWR('/api/weather', fetcher)
    if (error) return <div>failed to load</div>
    if (!data) return <div>loading...</div>
    return <div><p>In {data.location.city}, it feels like {data.location.weather.feelsLike} {data.location.weather.units} with {data.location.weather.description}.</p></div>
}

We're simply fetching the /api/weather endpoint. Since all backend calls are handled in that code, we don't need to hide any API keys, or worry about people misusing our backend API.

The second key part of the app is the API implementation in pages/api/weather.js. Since that code will be executed server-side, we can use API keys or other secrets, which we can set as environment variables. We can also implement logic that restricts how the backend service is called. In this case we supply weather based on the IP address of the request, rather than any location. This further limits the value of trying to re-use our API code, making it less attractive for abuse.

import { GraphQLClient, gql } from 'graphql-request'

const { STEPZEN_URL, STEPZEN_KEY } = process.env
const REFERERS=["https://weather-test.c3b.dev/", "http://localhost:3000/"]
// default ip if we fail to resolve or are running locally.
let ip = "128.101.101.101"

const graphQLClient = new GraphQLClient(STEPZEN_URL, {
    headers: {
        authorization: `Apikey ${STEPZEN_KEY}`
    }
})

export default async (req, res) => {
    // log headers, just while debugging. 
    console.log("headers: ", req.headers)
    
    // check sec-fetch-site header for same-origin
    if (req.headers["sec-fetch-site"] && req.headers["sec-fetch-site"] != "same-origin") {
        console.log("Not same-origin: ", req.headers["sec-fetch-site"])
        res.statusCode = 403
        res.send("FORBIDDEN")
        return
    } else if (! req.headers["sec-fetch-site"]) {
        // requiring sec-fetch-site header would current break on several popular browsers.
        // See https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Sec-Fetch-Site
    }

    // check referer header for known referrers
    if (req.headers["referer"] && ! REFERERS.includes(req.headers["referer"])) {
        console.log("Bad referer: ", req.headers["referer"])
        res.statusCode = 403
        res.send("FORBIDDEN")
        return
    }

    // query -- to find ip we check for x-forwarded-for header, then override that if the x-bb-ip header exists
    if (req.headers["x-forwarded-for"]) {
        ip = req.headers["x-forwarded-for"].split(',')[0]
    }
    if (req.headers["x-nf-client-connection-ip"]) {
        ip = req.headers["x-nf-client-connection-ip"]
    }
    let query = gql`
    {
        location(ip: "${ip}") {
            city
            weather {
                temp
                units
                feelsLike
                description
            }
        }
    }
    `
    
    try {
        const data = await graphQLClient.rawRequest(query)
        console.log(JSON.stringify(data))
        res.statusCode = 200
        res.json(data.data)
    } catch (error) {
        console.error("error:", error)
        res.statusCode = 500
        res.send("Server Error")
    }
}

Additionally, we've added a few checks to try to ensure the caller is coming from a known domain. Let's look at those a bit and talk about the limitations.

First, we check to see if the request has a sec-fetch-site header set to same-origin. The W3C has started some work on providing servers some context about how a request was made which would allow servers to decide to serve the request or not. sec-fetch-site is one of these. Not all browsers support this yet, so we don't block the request if it is not present. To learn more about this header, see here: Fetch Metadata Request Headers.

Next we check to see if the Referer header matches what we've defined in the REFERERS const. This is a simple way to see if the request is coming from our site (or locally).

In both checks, we "fail open" (if the headers aren't present, we allow the request through). Since we're already limiting what can be done with the API, we are not that worried about someone calling it directly from curl, for example. We could change the checks to require the presence of these or other headers if we wanted to be more restrictive.

Of course, these header checks can be circumvented by a caller if they know to try setting headers appropriately. It's not a locked gate, just a latched gate.

Our code also retrieves the caller's IP using either the X-Forwarded-For header, or if it's present, the x-nf-client-connection-ip header, which as of writing, is a header Netlify provides for us. See this community discussion topic for more information.

Finally, we execute the query against our StepZen endpoint.

Deploying to Netlify

Now that we have the code for our site and our API-wrapping serverless function, we can deploy the site to Netlify. I'm not going to explain that process in detail, since Netlify has great documentation already: Next on Netlify. I will point out a few things we do.

First, when setting up our site we add two environment variables that we use in our API function: const { STEPZEN_URL, STEPZEN_KEY } = process.env. These are set to the endpoint of our StepZen GraphQL API and our StepZen API key, respectively.

Secondly, we install a terrific plugin the Netlify team provides, the Next.js Build Plugin. This plugin will automatically create Netlify functions for any pages in your app that require server-side rendering, including anything you put in the /pages/api directory. This means we don't need to do any work at all to get our app working. No deploying a function manually, etc. Simply hook up your github repo and away you go.

In this example I also mapped my site to a custom domain, but that's not necessary. Just remember to adjust the REFERERS constant appropriately if you try this with your own deployment.

Once deployed to Netlify I can call up the page at https://weather-test.c3b.dev/ and see the current conditions at a location based on my IP address.

Screenshot of deployed web app

If I open up the developer console in my browser, I can see the fetch calls being executed with a request to https://weather-test.c3b.dev/api/weather:

Dev tools showing request details

You can see that no authentication information or details about the capabilities of the underlying APIs are revealed to the user. And if I try to hit that API URL directly from my browser I'll get a FORBIDDEN response, since the referer header is incorrect.

Recap

Tooling for building and deploying Jamstack apps is rapidly improving, but there are still some challenges with the "A" in Jamstack that need solving. Here we've looked at one way to approach hiding credentials and limiting capabilities to prevent your users from misusing your APIs.