When we send an API request to AWS, we must sign the request. We can use AWS SDKs to sign custom requests in our Lambda functions when the function invokes an API endpoint.
1. Scenarios when we must sign requests
When we call almost all AWS API endpoints, we must sign the request with our access key (access key id and secret access key). The signature verifies who we are, records the date and time we submitted the request, and protects the data in transit. Almost all endpoints require the Signature Version 4 signing process.
AWS CLI and the SDKs automatically sign the requests on behalf of us. They look for our access key on our computer or get it from the application's role.
But in some scenarios, we have to manually sign the request. These cases include the use of a programming language for which no SDK exists. Or, we use a supported language but we want to call a Lambda function URL or an endpoint behind an API Gateway which is protected by AWS IAM.
2. The problem
Say we have a Lambda function, which invokes an endpoint created by an API Gateway, where we have protected the endpoint with AWS_IAM
. We can use this type of protection when one microservice has to call another.
In this case the Lambda function will use axios to make the HTTP request.
3. Using AWS SDK for JavaScript
AWS SDK for JavaScript v3 provides modules for SigV4 signing.
We should install at least these two AWS packages and, of course, axios
:
npm install @aws-sdk/signature-v4 @aws-crypto/sha256-js axios
The @aws-sdk/signature-v4
package implements the SigV4 request signing algorithm, while @aws-crypto/sha256-js
is the JavaScript implementation of SHA256
.
The Lambda function which should sign the request can have the following code:
import axios from 'axios';
import { SignatureV4 } from '@aws-sdk/signature-v4';
import { Sha256 } from '@aws-crypto/sha256-js';
const {
API_URL,
AWS_ACCESS_KEY_ID,
AWS_SECRET_ACCESS_KEY,
AWS_SESSION_TOKEN
} = process.env;
const apiUrl = new URL(API_URL);
const sigv4 = new SignatureV4({
service: 'execute-api',
region: 'us-east-1',
credentials: {
accessKeyId: AWS_ACCESS_KEY_ID,
secretAccessKey: AWS_SECRET_ACCESS_KEY,
sessionToken: AWS_SESSION_TOKEN,
},
sha256: Sha256,
});
export const handler = async () => {
const signed = await sigv4.sign({
method: 'GET',
hostname: apiUrl.host,
path: apiUrl.pathname,
protocol: apiUrl.protocol,
headers: {
'Content-Type': 'application/json',
host: apiUrl.hostname, // compulsory
},
});
try {
const { data } = await axios({
...signed,
url: API_URL, // compulsory
});
console.log('Successfully received data: ', data);
return data;
} catch (error) {
console.log('An error occurred', error);
throw error;
}
};
We must specify some compulsory elements.
3.1. SignatureV4 class
credentials
in the SignatureV4
constructor contains the access key id, secret access key and session token of the Lambda function's execution role. Because the function assumes the role, the access key id and secret access key are not enough. Roles are temporary credentials, so we will need to specify the session token too.
Credentials come from the AWS_ACCESS_KEY_ID
, AWS_SECRET_ACCESS_KEY
and AWS_SESSION_TOKEN
runtime environment variables. Their values come from the Lambda execution role and are available from the process.env
object without further setup.
service
and region
are straightforward. If we want to call an endpoint in API Gateway, like in this case, service
will be execute-api
. In the case of a Lambda function URL, we should set the value of service
to lambda
. region
is hard-coded here, but we can make it dynamic by adding it as an environment variable to the function.
Sha256
is a constructor which uses a cryptographic hash function. SignatureV4
will calculate a hash value from parts of the request, which AWS will compare to its own generated checksum. If they match, the request can proceed.
3.2. axios request
We use the sign
method on the SignatureV4
instance to sign the request.
The method accepts the HttpRequest
we want to sign. The code above lists the minimum compulsory properties. We must also specify the host
header, otherwise, we will receive a 403
error.
sign
resolves with the signed HttpRequest
, so we can pass it to the axios
instance. Don't forget to specify the url
property in the axios
config object.
3.3. Invoking the function
We can now deploy and invoke the Lambda function. The request should be successful, and we should see the return value of the endpoint.
4. Other solutions
SignatureV4
in the SDK is not the only way to sign axios
requests.
We can create custom axios
clients for the requests we sign. Then we can intercept the requests using a package which builds on the popular (but apparently unmaintained) aws4 module.
5. Summary
AWS requires Signature Version 4 as a layer of protection for their API endpoints most of the time. The CLI and all SDKs automatically sign the requests, but we can encounter situations when an explicit signature process is necessary. One such scenario is when a Lambda function invokes an API that is protected by AWS_IAM
.
It's best to use the signature-v4
package, which is available in the AWS SDKs to sign requests in Lambda functions.
6. References
Signature Version 4 documentation - Details about the SigV4 process and how the signature is created.
Module @aws-sdk/signature-v4 - Official (but a bit dry and less than informative) documentation on the SDK's signature-v4
package.
Sign GraphQL Request with AWS IAM and Signature V4 - Great post about signing requests with AWS SDK for JavaScript v3. It uses fetch
instead of axios
.
Top comments (9)
Hi, I would like to know how stable your solution is? I check the aws4-axios package, but it is not maintained and not compatible with the current axios version. However, they do a lot of preprocessing of the request before it gets signed, for example (amongst other stuff), combining url and base url..
Hi, apologies for the late reply. I haven't tested it with the latest version of axios yet because it wasn't available as of writing the article. The solution worked well in my environment I set up for the post, however you might want to test it thoroughly if it suits your use case. If you find a way to make the solution more robust, please fee free to modify and share it for the benefit of the community.
Great article! I wanted to call a Lambda function url and had to change
to get it working (in case anyone else has the same problem)
Thanks! Yes, that's correct. For example, if you want to call a Lambda function URL, you'll need to change
service
tolambda
.dev.to/aws-builders/controlling-ac...
Awesome article. Can't find anywhere how to call a Lambda function URL that protected by IAM role.
Thank you for the great and up to date post on this!
However, I'm running into error claiming "The request signature we calculated does not match the signature you provided".
Note my API_URL.
Can you spot what am I missing please?
I'm terribly sorry for the late response. Hopefully I can soon come back to actively writing again, and I'll then address comments with an earlier response time.
Did you manage to figure it out? I guess it might be something with the URL provided (whitespace, invalid character etc.). Have you tried template strings instead of double quotes?
If you have found the solution, it would be great if you could share it with us. Thanks, and apologies again for not reacting earlier.
Hi Arpad.
Yes, I managed to work this out.
It seems that the combination of using Lambda + Neptune DB + openCypher is not tight yet. I think I was probably the first to try to stich all these together in real life scenario. I am also in contact with the AWS Neptune team to fix some issues.
To begin with, one cannot pass openCypher queries PARAMETERS as part of an object through Lambda to the Neptune DB. Only a single query string to be passed is supported. AS a result, I needed to inject all values into the query string on my app side, before sending out the request.
Also, the same query string, if passed to the Neptune DB and to the signerv4 - fails with the signature calculation mismatch error. This defies the whole idea of the signer :).
What I ended up doing, is manipulating the request query string for the signer to satisfy its needs, while passing a slightly different query string to the DB to satisfy its needs.
Consider the below Lambda code I now have working.
The ocQuery variable is receiving the query string from the request,
As you can see, it is passed on as part of the API_URL as a query string to the Neptune endpoint call.
Now, according to the signature v4 documentation, the exact same string must be also included in the signer payload, as these are checked to match by the signer! This should be a core feature of the signer service – matching the signed payload with the querystring passed!.
However, as you can see, I an not passing ocQuery to the signer, but rather another variable: signatureQuery, that is constructed after decoding some special characters I have to decode in the request if I want the Neptune reader to parse correctly.
`import axios from 'axios';
import { SignatureV4 } from '@aws-sdk/signature-v4';
import { Sha256 } from '@aws-crypto/sha256-js';
const {
AWS_ACCESS_KEY_ID,
AWS_SECRET_ACCESS_KEY,
AWS_SESSION_TOKEN,
CALL_SIGNATURE,
NEPTUNE_DB_ENDPOINT_dev,
NEPTUNE_DB_ENDPOINT_tst,
NEPTUNE_DB_ENDPOINT_prd
} = process.env;
const sigv4 = new SignatureV4({
service: 'neptune-db',
region: 'us-east-1',
credentials: {
accessKeyId: AWS_ACCESS_KEY_ID,
secretAccessKey: AWS_SECRET_ACCESS_KEY,
sessionToken: AWS_SESSION_TOKEN,
},
sha256: Sha256,
});
const validateSignature = async (requestSignature) => {
//Lambda call permission validator
if (requestSignature!=CALL_SIGNATURE) {
throw new Error('Function call signature does not match');
}
};
const decodeQuery = async (queryText) => {
//Decodes some chars in the raw query text for Lambda to process properly
let result = queryText.replace(/%7B/g, "{");
result = result.replace(/%7D/g, "}");
result = result.replace(/%3D/g, "=");
result = result.replace(/%60/g, "`");
result = result.replace(/%2B/g, "+");
return result;
};
const getNeptuneDbEndpoint = (alias) => {
switch(alias) {
case 'prd':
return NEPTUNE_DB_ENDPOINT_prd;
case 'tst':
return NEPTUNE_DB_ENDPOINT_tst;
case 'dev':
return NEPTUNE_DB_ENDPOINT_dev;
default:
return '';
}
};
export const handler = async (event, context) => {
// retrieve signature and payload
const requestSignature = event.headers.SignatureHeader;
const functionAlias = event.headers.functionAlias;
//const requestSignature = event.body.SignatureHeader;
console.log('event received: ', event);
console.log('functionAlias: ' + functionAlias);
try {
await validateSignature(requestSignature); // throws if invalid signature
} catch (error) {
console.error(error);
return {
statusCode: 400,
body:
Function permission error: ${error}
,};
}
const requestPayload = event.body; //Passed openCypher query string
console.log('requestPayload: ', requestPayload);
const signatureQuery = await decodeQuery(requestPayload);
const ocQuery = requestPayload;
const dbEndpoint = getNeptuneDbEndpoint(functionAlias);
const API_URL = https:// + dbEndpoint + ":8182/openCypher?query=" + ocQuery;
const apiUrl = new URL(API_URL);
const signed = await sigv4.sign({
method: 'GET',
hostname: apiUrl.host,
path: apiUrl.pathname,
protocol: apiUrl.protocol,
query: {
query: signatureQuery
},
headers: {
'Content-Type': 'application/json',
host: apiUrl.hostname,
}
});
try {
const result = await axios({
...signed,
url: API_URL,
});
} catch (error) {
console.log('An error occurred', error);
}
};
`
Thanks for the follow-up and the contribution!