Signing requests to AWS services using axios
Tags: aws, security, typescript, node
Published: 2022-04-21 05:29:27

## 1. The problem

I played with the new Lambda <a href="https://docs.aws.amazon.com/lambda/latest/dg/lambda-urls.html" class="link" target="_blank" rel="noopener noreferrer">function URLs</a> 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 <a href="https://dev.to/aws-builders/controlling-access-to-lambda-function-urls-idd" class="link" target="_blank" rel="noopener noreferrer">two types of authorization</a>: `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 <a href="https://aws.amazon.com/lambda/" class="link" target="_blank" rel="noopener noreferrer">Lambda</a> 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 <a href="https://www.npmjs.com/package/axios" class="link" target="_blank" rel="noopener noreferrer">axios</a>?

## 2. A few words about AWS signatures

Most API requests to AWS services must be signed using the <a href="https://docs.aws.amazon.com/general/latest/gr/signature-version-4.html" class="link" target="_blank" rel="noopener noreferrer">Signature Version 4 (SigV4)</a> 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 <a href="https://aws.amazon.com/getting-started/tools-sdks/" class="link" target="_blank" rel="noopener noreferrer">SDKs</a> or the AWS <a href="https://aws.amazon.com/cli/" class="link" target="_blank" rel="noopener noreferrer">CLI</a>, 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:

```json
{
  "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 <a href="https://www.typescriptlang.org/" class="link" target="_blank" rel="noopener noreferrer">TypeScript</a> and axios to create some solutions for the scenario. <a href="https://developer.mozilla.org/en-US/docs/Web/API/Fetch" class="link" target="_blank" rel="noopener noreferrer">Fetch API</a> can also be used with a library like <a href="https://www.npmjs.com/package/node-fetch" class="link" target="_blank" rel="noopener noreferrer">node-fetch</a>.

### 4.1. Signing individual requests - aws4 library

When we want to sign a single request, we can use the <a href="https://www.npmjs.com/package/aws4" class="link" target="_blank" rel="noopener noreferrer">aws4</a> 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:

```ts
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:

```ts
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

<a href="https://docs.aws.amazon.com/general/latest/gr/signature-version-4.html" class="link" target="_blank" rel="noopener noreferrer">Signature Version 4 documentation</a> - Details about the SigV4 process and how the signature is created.