Signing requests to AWS services using axios

Most requests to AWS must be signed using the Sigv4 signature. When we call an API directly and don't use the CLI or SDK, we will have to sign the requests in the code.

1. The problem

I played with the new Lambda function URLs the other day, and I wanted to simulate a service-to-service communication where a service invokes a Lambda function URL.

It’s an HTTP call, so I couldn’t use the SDK or the CLI for invoking the function.

Function URLs can be one of two types of authorization: AuthType: AWS_IAM and AuthType: NONE. URLs with AuthType: AWS_IAM require that requests should be signed.

The scenario is valid for not only Lambda function URLs but other services, too, where we can’t use SDK. I used a function-to-function architecture because Lambda functions are easy to set up and tear down.

So my question was how can I sign a request to an AWS HTTP endpoint using axios?

2. A few words about AWS signatures

Most API requests to AWS services must be signed using the Signature Version 4 (SigV4) process. SigV4 adds an authentication layer to the request using the calling identity’s (user or role) credentials (access key ID and secret access key).

Signing ensures that the calling identity is verified and no one has compromised the data in transit. The service that requires the signed request calculates the signature hash, and if it doesn’t match the one in the request, the service will deny the request.

We can add the signature to either the Authorization header or the URL as a query string (presigned URL).

When we use one of the SDKs or the AWS CLI, the tools will automatically sign the request with the requester’s credentials.

This post is about signing a request when we don’t use the SDK or CLI.

3. Pre-requisites

If we want to invoke the URL of a service (in our case, it’s a Lambda function URL), the calling service (also a Lambda function here) must have the relevant permissions.

The following snippet is an example of such permission:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": "lambda:InvokeFunctionUrl",
      "Resource": "arn:aws:lambda:us-east-1:123456789012:function:NameOfTheFunction",
      "Condition": {
        "StringEquals": {
          "lambda:FunctionUrlAuthType": "AWS_IAM"
        }
      }
    }
  ]
}

We should attach this policy to the assumed role of the service that invokes the URL.

4. Solutions

I used TypeScript and axios to create some solutions for the scenario. Fetch API can also be used with a library like node-fetch.

4.1. Signing individual requests - aws4 library

When we want to sign a single request, we can use the aws4 package. I can’t say for sure, but I think it’s probably the most popular SigV4 library with its approximately 20 million weekly downloads.

The following very basic code contains a signed single request:

import { sign } from 'aws4';
import axios, { Method } from 'axios'

interface SignedRequest {
  method: Method;
  service: string;
  region: string;
  host: string;
  headers: Record<string, string>;
  body: string;
}

const { FUNCTION_URL } = process.env
const functionUrl = FUNCTION_URL ?? ''
const { host } = new URL(functionUrl)

export default async function(): Promise<void> {
  const signed = sign({
    method: 'POST',
    service: 'lambda',
    region: 'us-east-1',
    host,
    headers: {
      'Content-Type': 'application/json'
    },
    body: JSON.stringify({ test: 'aws4 message' }),
  }) as SignedRequest

  try {
    const response = await axios({
      ...signed,
      url: functionUrl,
      data: { test: 'aws4 message' },
    })

    console.log(response.data)
  } catch (error) {
    console.error('Something went wrong: ', error)
    throw error
  }
}

We use the sign method of the aws4 package to sign the request.

I used typecasting because there are inconsistencies between AxiosRequestConfig (required by axios) and Node.js Request (used by aws4) interfaces. axios uses the type Method for method while Request needs a string type.

The other issue is that axios requires the url and data keys in the config object, so we must specify them outside the signed request. body in the signed request is the stringified version of the data object, and it will be part of the signature.

method defaults to POST when the body property has a value (defaults to empty string), but I prefer displaying it for better readability.

service and region are necessary properties, so we must specify them in the payload we want to sign. Because my service invokes a Lambda function URL, I wrote service: lambda. This property will change if we need to call a different service.

4.2. Signing all requests - aws4-axios library

The aws4-axios package intercepts and signs the axios requests before the service sends them. The package uses aws4 under the hood and takes care of all type mismatches and any necessary mappings between AxiosRequestConfig and Request. It can also handle URLs with query parameters. We can also attach the interceptor to a single axios client if needed.

The following basic code is an example of a successful function URL invocation:

import axios from 'axios'
import { aws4Interceptor } from 'aws4-axios'

const { FUNCTION_URL } = process.env
const functionUrl = FUNCTION_URL ?? ''

const interceptor = aws4Interceptor({
  region: 'us-east-1',
  service: 'lambda'
})

axios.interceptors.request.use(interceptor)

export default async function(): Promise<void> {
  try {
    const response = await axios({
      method: 'POST',
      url: functionUrl,
      data: { test: 'message' },
      headers: {
        'Content-Type': 'application/json'
      }
    })

    console.log(response.data)
  } catch (error) {
    console.error('Something went wrong: ', error)
    throw error
  }
}

It looks like a more usual axios request. We must specify both the service and region properties in the interceptor payload. The library will then extract everything we need for the signature from the axios request config.

5. Conclusion

Most AWS services require signed requests. When not using the SDK or CLI, we can sign single requests using the aws4 package or intercept any HTTP requests with the aws4-axios library in Node.js.

We have to specify the service and region properties for both libraries, and the service will use the credentials of the calling identity to sign the request.

6. References and further reading

Signature Version 4 documentation - Details about the SigV4 process and how the signature is created.