Signing requests to AWS services using axios
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.