Secure your API resources for machine-to-machine communication

Learn how to leverage OAuth 2.0 and JWT to secure your API resources for machine-to-machine communication.
Gao
GaoFounder
November 07, 20239 min read
Secure your API resources for machine-to-machine communication

When building a project that involves multiple services, the security of the API resources is a critical concern. In this article, I'll show you how to leverage OAuth 2.0 and JWT to secure the communication between services (machine-to-machine), and how to apply role-based access control (RBAC) to follow the minimum privilege principle.

Get started

To follow along, I assume you have the following prerequisites:

  • A Logto Cloud account, or a self-hosted Logto instance
  • At least two services that need to communicate with each other

For demonstration purposes, let's assume we have the following services:

  • A shopping cart service that provides APIs to manage shopping carts
    • Endpoint: https://cart.example.com/api
  • A payment service that provides APIs to process payments
    • Endpoint: https://payment.example.com/api

Authentication flow

Now, our cart service needs to call the payment service to process payments. The authentication flow is as follows:

Payment serviceLogtoCart servicePayment serviceLogtoCart servicealt[Local JWK set cache notfound]Request access token (`client_credentials` grant)1Return access token (JWT)2Send request with authorization header (access token)3Fetch well-known JWK set4Return JWK set5Validate JWT using JWK set6Return response7

Some key concepts in the above diagram:

  • JWT (RFC 7519): JSON Web Token. See our previous article for an introduction to JWT.
  • JWK (RFC 7517): JSON Web Key which is used to verify the signature of a JWT. A JWK set is a set of JWKs.
  • "client_credentials" grant (RFC 6749): A grant type in OAuth 2.0. It uses the client's credentials to obtain an access token. We'll demonstrate the details in the upcoming sections.

Each participant in the above diagram has a role to play in the authentication flow:

  • Cart service: The client that needs to call the payment service. Although it's a service, it's still a client in the OAuth 2.0 context, and we call such clients "machine-to-machine applications" in Logto.
  • Logto: The OAuth 2.0 authorization server that issues access tokens.
  • Payment service: The API resource that provides APIs to process payments.

Let's go through the authentication flow step by step.

Initial setup

To perform the authentication flow, we need to create a machine-to-machine app (cart service) and an API resource (payment service) in Logto.

Create API resource

Since our cart service needs to be aware of the payment service's API when performing authentication, we need to create an API resource first. Go to Logto Console, click API resources in the left sidebar, and click Create API resource. In the opened dialog, we offer some tutorials to help you get started. You can also click Continue without tutorial to skip it.

Enter the API name and identifier, for example, Payment service and https://payment.example.com/api, then click Create API resource.

The API identifier is used to identify the API resource. It must be a valid URI, and a good practice is to use the real API endpoint as the identifier.

After creating the API resource, you'll be redirected to the details page. We can leave it as is for now.

Create machine-to-machine app

Click Applications in the left sidebar, and click Create application. In the opened dialog, find the Machine-to-machine card, then click Start building.

Enter the application name, for example, Cart service, and click Create application. An interactive guide will be shown to help you set up the application. You can follow the guide to understand the basic usage, or click Finish and done to skip it.

Request access token

Since machine-to-machine applications are assumed to be secure (e.g., they are deployed in a private network), we can use the OAuth 2.0 "client_credentials" grant to obtain an access token. It uses basic authentication to authenticate the client:

  • The request URL is the token endpoint of your Logto instance. You can find and copy it in the Advanced settings tab of the machine-to-machine app details page.
  • The request method is POST.
  • The request Content-Type header is application/x-www-form-urlencoded.
  • For the Authorization header, the value is Basic <base64(app_id:app_secret)>, where app_id and app_secret are the app ID and app secret of the machine-to-machine app respectively. You can find them in the application details page.
  • The request body needs to specify the grant type and the API identifier. For example, grant_type=client_credentials&resource=https://payment.example.com/api.
    • grant_type=client_credentials: The constant value for the "client_credentials" grant.
    • resource=https://payment.example.com/api: The API identifier of the API resource that the client wants to access.
    • If the application needs to be authorized with scopes (permissions), you can also specify the scopes in the request body. For example, scope=read:payment write:payment. We'll cover scopes later.

Here's an example of the request using curl:

curl --location
  --request POST 'https://example.logto.app/oidc/token'
  --header 'Content-Type: application/x-www-form-urlencoded'
  --header 'Authorization: Basic Y2xp...' # Replace with your own value
  --data-urlencode 'grant_type=client_credentials'
  --data-urlencode 'resource=https://payment.example.com/api'

A successful response body would be like:

{
  "access_token": "<granted-access-token>", // Use this token to access the API resource
  "expires_in": 3600, // Token expiration in seconds
  "token_type": "Bearer" // Auth type for your request when using the access token
}

Send request with authorization header

Now we have the access token, and we can append it to the Authorization header of the request to the API resource. For example, if we want to call the POST /payments API of the payment service, we can send the following request:

curl --location
  --request POST 'https://payment.example.com/api/payments'
  --header 'Authorization: Bearer <granted-access-token>' # Replace with your own value
  # Other request headers and body

Validate JWT

You may notice that the payment service needs to validate the JWT using the JWK set, and may have a local JWK set cache to avoid fetching the JWK set from Logto every time. Fortunately, due to the popularity of JWT, there are many libraries that can help you to achieve the goal with several lines of code.

These libraries are usually called "jose" (JavaScript Object Signing and Encryption) or "jsonwebtoken". For instance, in Node.js we can use jose to validate the JWT:

import { createRemoteJWKSet, jwtVerify } from 'jose';

// Create a remote JWK set that fetches the JWK set from Logto with caching
const jwks = createRemoteJWKSet(new URL('https://example.logto.app/oidc/jwks'));

const { payload } = await jwtVerify(
  token: '<granted-access-token>', // Replace with your own value
  jwks,
  {
    issuer: 'https://example.logto.app/oidc', // Expect the token issuer to be your Logto instance
    audience: 'https://payment.example.com/api', // The API identifier of the API resource to be accessed
  }
);

If the validation succeeds, the payload variable will be the decoded JWT payload. Otherwise, an error will be thrown.

Apply role-based access control

Now we have successfully secured the communication between the cart service and the payment service. However, the authentication flow only ensures that the client is the real cart service, but doesn't ensure that the cart service has any permission to perform actions on the payment service.

Let's say we want to allow the cart service to create payments, but not to read payments.

Define permissions

In Logto, "scopes" and "permissions" are interchangeable. Go to the API resource details page of the payment service, and navigate to the Permissions tab. It should be empty now. Click Create permission, enter read:payment as the permission name, and enter Read payments as the permission description. Then click Create permission.

Repeat the above steps to create another permission with the name write:payment and the description Create payments.

Create machine-to-machine role

A role is a group of permissions. In Logto, machine-to-machine apps can be assigned roles to grant permissions. Click "Roles" in the left sidebar, and click Create role.

  1. Enter checkout as the role name, and enter Checkout service as the role description.
  2. Click Show more options. Choose "Machine-to-machine app role" as the role type.
  3. In the "Assigned permissions" section, click the arrow icon on the left of the API resource name (Payment service), and select the write:payment permission.
    Create role
  4. Click Create role.
  5. Since we already have a machine-to-machine app (Cart service), we can directly assign the role to it in the next step. Check the checkbox on the left of the app name (Cart service), and click Assign applications.
Assign role to application

Request access token with scopes

In addition to the request body parameters we mentioned in Request access token, we can also specify the scopes in the request body. For example, if we want to request the write:payment permission, we can send the following request:

curl --location
  --request POST 'https://example.logto.app/oidc/token'
  --header 'Content-Type: application/x-www-form-urlencoded'
  --header 'Authorization: Basic Y2xp...' # Replace with your own value
  --data-urlencode 'grant_type=client_credentials'
  --data-urlencode 'resource=https://payment.example.com/api'
  --data-urlencode 'scope=write:payment'

To request multiple scopes, you can separate them with spaces. For example, scope=write:payment read:payment.

Try to request the read:payment permission and see what happens.

Validate scopes

If an action needs the write:payment permission in the payment service, we can validate the scopes by asserting that the scope claim of the JWT payload:

const { payload } = await jwtVerify(/* ... */);

assert(payload.scope.split(' ').includes('write:payment'));

Conclusion

If you want to protect the access to the cart service, you can also apply the same authentication flow. This time, the cart service is the API resource, and the client is another service that needs to access.

With Logto, your API resources are secured with OAuth 2.0 and JWT, and the you can follow the minimum privilege principle by applying role-based access control. Besides, you can also use Logto to manage your users and their permissions, and even integrate with third-party identity providers.