Protect your Express.js API with JWT and Logto

Learn how to protect your Express.js API endpoints with JSON Web Tokens (JWT) and Logto.
Gao
GaoFounder
September 18, 20236 min read
Protect your Express.js API with JWT and Logto

Introduction

When you're developing a web application, it's crucial to protect your API endpoints from unauthorized access. Imagine you're building an online shopping website; you definitely don't want cyber shoplifters exploiting your API.

Assuming you've already built an Express.js application with user authentication, where users must sign in before taking certain actions. If not, you can kickstart your journey with Logto. It requires just a few lines of code to establish a user authentication flow.

However, even after user authentication, you face various choices to protect your API endpoints. Unfortunately, most of these options have their downsides:

  • Session-based authentication: Tying your API to a session store, which isn't scalable and doesn't suit microservices well.
  • Calling an authentication service: This introduces an extra network call, increasing latency and costs. Some authentication services even charge based on API call volume, potentially leading to hefty expenses.

In this tutorial, we'll demonstrate how to fortify your API endpoints using JSON Web Tokens (JWT) and Logto. This approach offers scalability and minimal extra costs.

Prerequisites

Before diving in, ensure you have the following:

  • A Logto account. If you don't have one, you can sign up for free.
  • An Express.js project that needs API protection and a client application that consumes the API.
  • Basic familiarity with JSON Web Token (JWT).

We understand that the RFC can be lengthy. In short, a JWT is a base64-encoded string comprising three parts: header, payload, and signature. The payload stores the information you want, while the signature ensures token integrity.

Define your API resource in Logto

Logto takes full advantage of RFC 8707: Resource Indicators for OAuth 2.0 to secure your API endpoints. This means you can define your API resources using their actual URLs.

Navigate to the "API resources" tab in the Logto Console and click "Create API resource" to create a new one. For instance, if you wish to protect the /api/products endpoint, you can use the URL https://yourdomain.com/api/products as the identifier.

Create API resource

Obtain an access token in your client application

To proceed, you'll need to integrate the Logto SDK into your client application. This application might differ from your Express.js backend; for example, you might have a React app using Express.js as the backend API server.

You'll also need to tweak the Logto SDK configuration to inform Logto that you want to request an access token for your API in this grant. Here's an example using React:

import { LogtoProvider } from '@logto/react';

const App = () => {
  return (
    <LogtoProvider
      config={{
        // ...other configurations
        resources: ['https://yourdomain.com/api/products'],
      }}
    >
      <Content />
    </LogtoProvider>
  );
};

Once a user signs in with Logto, isAuthenticated within the Logto SDK will become true:

import { useLogto } from '@logto/react';

const Content = () => {
  const { isAuthenticated } = useLogto();

  console.log(isAuthenticated); // true
};

Now, you can use the getAccessToken method to retrieve an access token for your API:

const Content = () => {
  const { getAccessToken, isAuthenticated } = useLogto();

  useEffect(() => {
    if (isAuthenticated) {
      const accessToken = await getAccessToken('https://yourdomain.com/api/products');
      console.log(accessToken); // eyJhbG...
    }
  }, [isAuthenticated, getAccessToken]);
};

Lastly, include this access token in the Authorization header when making requests to your API:

const Content = () => {
  const { getAccessToken, isAuthenticated } = useLogto();

  useEffect(() => {
    if (isAuthenticated) {
      const accessToken = await getAccessToken('https://yourdomain.com/api/products');
      const response = await fetch('https://yourdomain.com/api/products', {
        headers: {
          Authorization: `Bearer ${accessToken}`,
        },
      });
    }
  }, [isAuthenticated, getAccessToken]);
};

Verify the access token in your API

In your Express.js application, install the jose library for JWT verification:

npm install jose

As we're using Bearer authentication, extract the access token from the Authorization header:

import { IncomingHttpHeaders } from 'http';

const extractBearerTokenFromHeaders = ({ authorization }: IncomingHttpHeaders) => {
  if (!authorization) {
    throw new Error('Authorization header is missing');
  }

  if (!authorization.startsWith('Bearer')) {
    throw an Error('Authorization header is not in the Bearer scheme');
  }

  return authorization.slice(7); // The length of 'Bearer ' is 7
};

Subsequently, create a middleware to verify the access token:

import { createRemoteJWKSet, jwtVerify } from 'jose';

// Generate a JWKS using jwks_uri obtained from the Logto server
// Replace `<your-logto-endpoint>` with your Logto endpoint, e.g., `abc123.logto.app`
const jwks = createRemoteJWKSet(new URL('https://<your-logto-endpoint>/oidc/jwks'));

export const authMiddleware = async (req, res, next) => {
  // Extract the token using the helper function defined above
  const token = extractBearerTokenFromHeaders(req.headers);

  const { payload } = await jwtVerify(
    // The raw Bearer Token extracted from the request header
    token,
    jwks,
    {
      // Expected issuer of the token, issued by the Logto server
      issuer: 'https://<your-logto-endpoint>/oidc',
      // Expected audience token, the resource indicator of the current API
      audience: 'https://yourdomain.com/api/products',
    }
  );

  // Sub is the user ID, used for user identification
  const { scope, sub } = payload;

  // For role-based access control, we'll discuss it later
  assert(scope.split(' ').includes('read:products'));

  return next();
};

You can now employ this middleware to protect your API endpoints:

app.get('/api/products', authMiddleware, (req, res) => {
  // ...
});

With this approach, you don't need to contact the Logto server every time a request arrives. Instead, you fetch the JSON Web Key Set (JWKS) from the Logto server once and subsequently verify access tokens locally.

Role-based access control

Up to this point, we've only verified that a user has logged in with Logto. We still don't know if the user possesses the appropriate permission to access the API endpoint. This is because Logto permits anyone to obtain an access token for an existing API resource.

To address this, we can employ role-based access control (RBAC). In Logto, you can define roles and assign permissions to them. Consult this tutorial to learn how to define roles and permissions in Logto.

After defining roles and permissions, you can add the scopes option to the LogtoProvider component:

<LogtoProvider
  config={{
    // ...other configurations
    resources: ['https://yourdomain.com/api/products'],
    scopes: ['read:products', 'write:products'], // Replace with the actual scope(s)
  }}
>

Logto will then only issue an access token with the appropriate scope(s) to the user. For instance, if a user only has the read:products scope, the access token will solely contain that scope:

{
  "scope": "read:products",
  "sub": "1234567890"
}

If a user has both the read:products and write:products scopes, the access token will contain both scopes with a space as the delimiter:

{
  "scope": "read:products write:products",
  "sub": "1234567890"
}

In your Express.js application, you can verify if the access token contains the correct scope(s) before granting access to the API endpoint:

assert(scope.split(' ').includes('read:products'));

Conclusion

Protecting API endpoints while ensuring scalability is no small feat. At Logto, we strive to simplify request authentication for developers, allowing you to focus more on your business logic.

For any questions, feel free to join our Discord server. Our community is always happy to assist you.