Go back

Masking endpoints with Next.js API routes

Although, it is common to approach the aspect of security on the frontend that deals with protecting the API endpoints or URLs that point to such endpoints with environment variables intending to keep attackers off!

But, I hate to break it to you that, doing that — storing your API baseURL as an environment variable — may not completely save you from potential developers like me who snoop around opening dev tools trying to understand the "engineering" behind a particular product and attackers again, in general

I remember giving a talk about protecting API keys on the server with API routes about a year and some months ago, and I followed it up with an article on Smashing Magazine, you should take a look. Little did I know I'd be needing that approach again.

So what's wrong with using environment variables?

One of the most common issues with keeping API urls or endpoints in environment variables is that anyone can still access this variable, because one way or the other, you still need to provide this variable in the dashboard of the frontend deployment service — like Netlify or Vercel — you're using.

And then, we need to constantly update the environment variable when there's any change. In situations where you're using external API services, this becomes even more cumbersome.

That you update and provide the values of such variables in production makes it available for anyone who dares to inspect your app in the browser.

Using Next.js API routes

When I realized that I could prevent an API key from being accessed by anyone, I had to believe that this would be possible, since Next.js provided the abstraction I needed. Below, you'll find two advantages of using API routes (that stood out to me) to protect these endpoints instead of environment variables;

  • When you use Next.js API routes to handle your API requests, you can add a layer of security to your application. If you store your API endpoint in an environment variable and use it directly in your front-end code, anyone may be able to discover the endpoint URL by inspecting your code.

    By masking the endpoint behind a custom /api endpoint, you can help prevent people from discovering the actual endpoint URL.

  • Using API routes helps improve the readability of your code. So instead of doing something like what's in the snippet below with the hardcoded URL

    const request = await fetch('https://example-api/v1/users')
    const response = await request.json()

    You can isolate this request in the /api folder in your Next.js app, say, the endpoint is a user endpoint. The request URL becomes:

    const request = await fetch('/api/users')
    const response = await request.json()

Let's see how this works in full now, shall we?

Recently, I had to work on the authentication flow of a project and the need to prevent the API endpoints I'm consuming became a necessity. Normally, the common approach I took was hardcoding the URL in the request, and as time goes on, I'll add the baseURL as an environment variable

NEXT_PUBLIC_BASE_URL=https://example-auth-api/v1/

Then, for every endpoint, I'll proceed to use it like this:

const request = await fetch(`${NEXT_PUBLIC_BASE_URL}/login`)
const response = await request.json()

But, the thought of having to ask the person in charge of adding this environment variable to the deployed app on Vercel felt a bit too much for me. What happens when we have another environment variable in the future? Or when we get tons of them — because we'd definitely get such.

That did not seem too optimal for me. So, I went on with the API routes idea; I'm sending a payload that contains the email and password of the user.

// /pages/api/login.ts
import { NextApiRequest, NextApiResponse } from 'next'
 
export default async function login(req: NextApiRequest, res: NextApiResponse) {
  const { email, password } = req.body
 
  const request = await fetch('https://example-auth-api/v1/login/', {
    method: 'POST',
    body: JSON.stringify({ email, password }),
    headers: {
      'Content-Type': 'application/json',
    },
  })
 
  const response = await request.json()
 
  res.status(request.status).json(response)
}

These values need to be accessed from the request body, hence the reason I'm destructuring them from req.body, then I proceed by returning the status of the request.

To elude TypeScript's constant yelling that res and req are of type any I had to infer their types by importing NextApiRequest and NextApiResponse from nextjs. If you try doing this same thing with JavaScript, there's no need to import these parameters.

Proceeding to use this API route in your component follows the same pattern, the only thing that changes is the URL parameter;

export default function FormComponent() {
  const handleSubmit = async () => {
    const request = await fetch('/api/login', {
      method: 'POST',
      body: JSON.stringify({
        password: values.password,
        email: values.email,
      }),
      headers: {
        'Content-Type': 'application/json',
      },
    })
 
    const result = await request.json()
  }
 
  return (
    <>
      <Form onSubmit={handleSubmit}>// jsx</Form>
    </>
  )
}

What about protected resources?

Yes, it is a common thing for folks who have done things related to authentication in the past; Here's a typical scenario:

  • User signs up and tries to log in with their credentials

  • If the credentials match, they're redirected to the application dashboard.

    But, to get access to data available for that user, you'd need to provide an authentication token — most times, it is a JWT (JSON Web Token) — that is assigned to that user.

    This token will be used to authorize your request for protected resources on the server. It is a common pattern in basic HTTP Authentication. You may have come across this particular way of assigning a token or API key to the authorization header in a request:

    const request = async fetch("/api/login", {
      method: "GET",
      headers: {
        Authorization: `bearer ${token}`
      }
    })
  • If the credential is valid, the server returns the corresponding data.

The same approach is used when you want to obtain protected resources from the server, and you need to use API routes.

An ideal way to ensure that the token is accessed from the API route request is to access the authorization header like so:

// pages/api/getuser-info
import { NextApiRequest, NextApiResponse } from 'next'
 
export default async function getWorkspaces(
  req: NextApiRequest,
  res: NextApiResponse
) {
  const token = req.headers.authorization
 
  const request = await fetch('https://example-auth-api.com/v1/user-info/', {
    method: 'GET',
    headers: {
      'Content-Type': 'application/json',
      Authorization: `${token}`,
    },
  })
 
  const response = await request.json()
  res.status(request.status).json(response)
}

Now, using this API route follows the same precedence as the login route, the only difference here is the use of an authorization header and the fact that it is a GET request.

export default function UserComponent() {
  const [user, setUser] = React.useState([])
 
  const getUserInfo = async () => {
    const request = await fetch('/api/getuser-info', {
      method: 'GET',
      headers: {
        'Content-Type': 'application/json',
        Authorization: `Token ${token}`,
      },
    })
 
    const response = await request.json()
    const { data } = response
    setUser(data)
  }
 
  return (
    <>
      <div>
        {user.map(({ name, email }, index) => {
          return (
            <>
              <p>{name}</p>
              <p>{email}</p>
            </>
          )
        })}
      </div>
    </>
  )
}

Your own authorization parameter may be different from mine — Token ${token}. Yours could be bearer ${token}.

It depends on the specs your backend team has provided for you. Try communicating with them to know more, if and when you get stuck.

And, if there's an API documentation, you may be in luck, all the information you need may be there.