DEV Community

Arpad Toth for AWS Community Builders

Posted on • Originally published at arpadt.com

Signing requests with AWS SDK in Lambda functions

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
Enter fullscreen mode Exit fullscreen mode

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;
  }
};
Enter fullscreen mode Exit fullscreen mode

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)

Collapse
 
florianbepunkt profile image
Florian Bischoff

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..

Collapse
 
arpadt profile image
Arpad Toth

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.

Collapse
 
axeljunker profile image
AxelJunker

Great article! I wanted to call a Lambda function url and had to change

const sigv4 = new SignatureV4({
  service: 'lambda', // not 'execute-api'
  ...
)}
Enter fullscreen mode Exit fullscreen mode

to get it working (in case anyone else has the same problem)

Collapse
 
arpadt profile image
Arpad Toth

Thanks! Yes, that's correct. For example, if you want to call a Lambda function URL, you'll need to change service to lambda.

dev.to/aws-builders/controlling-ac...

Collapse
 
xiangweilee profile image
Lee Xiang Wei

Awesome article. Can't find anywhere how to call a Lambda function URL that protected by IAM role.

Collapse
 
morsagmon profile image
Mor Sagmon • Edited

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?

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
} = process.env;

const ocQuery = "MATCH%20(n)%20RETURN%20n";
const API_URL = "https://db-simplify-mvp-dev-instance-1.cwezylrm9ic8.us-east-1.neptune.amazonaws.com:8182/openCypher?query=" + ocQuery;

const apiUrl = new URL(API_URL);

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,
});


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 { result } = await axios({
      ...signed,
      url: API_URL, // compulsory
    });

    console.log('Successfully received result: ', result);
    return result;
  } catch (error) {
    console.log('An error occurred', error);

    throw error;
  }
};
Enter fullscreen mode Exit fullscreen mode
Collapse
 
arpadt profile image
Arpad Toth

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.

Collapse
 
morsagmon profile image
Mor Sagmon

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,
});

console.log('Successfully received result: ', JSON.stringify(result.data));
return {
  statusCode: 200,
  body: result.data
};
Enter fullscreen mode Exit fullscreen mode

} catch (error) {
console.log('An error occurred', error);

throw error;
Enter fullscreen mode Exit fullscreen mode

}
};
`

Thread Thread
 
arpadt profile image
Arpad Toth

Thanks for the follow-up and the contribution!