For most of the code, I followed along with this python example provided by AWS, making the necessary changes for JS/node.
import axios from 'axios'
import {createHash, createHmac} from 'crypto'
import moment from 'moment'
async function send() {
const method = 'POST';
const service = 'execute-api';
const host = 'fjakldfda.execute-api.us-east-2.amazonaws';
const region = 'us-east-2';
const base = "https://"
// POST requests use a content type header. For DynamoDB,
// the content is JSON.
const content_type = 'application/json';
// DynamoDB requires an x-amz-target header that has this format:
// DynamoDB_<API version>.<operationName>
//##...but I don't! Blank this out.
const amz_target = '';
// Request parameters for CreateTable--passed in a JSON block.
var request_parameters = `{"date":"today","content":"hello"}`
// Key derivation functions. See:
// .html//signature-v4-examples-python
function sign(key, msg) {
return createHmac('sha256', key).update(msg).digest('utf-8')
}
function getSignatureKey(key, date_stamp, regionName, serviceName) {
var kDate = sign(('AWS4' + key), date_stamp)
var kRegion = sign(kDate, regionName)
var kService = sign(kRegion, serviceName)
var kSigning = sign(kService, 'aws4_request')
return kSigning
}
// Read AWS access key from env. variables or configuration file. Best practice is NOT
// to embed credentials in code.
const access_key = "<CRED>"
const secret_key = "<CRED>"
// Create a date for headers and the credential string
const amz_date = moment().utc().format("yyyyMMDDThhmmss") + "Z"
const date_stamp = moment().utc().format("yyyyMMDD")
// ************* TASK 1: CREATE A CANONICAL REQUEST *************
// .html
// Step 1 is to define the verb (GET, POST, etc.)--already done.
// Step 2: Create canonical URI--the part of the URI from domain to query
// string (use '/' if no path)
const canonical_uri = '/prod/events/add-event'
//// Step 3: Create the canonical query string. In this example, request
// parameters are passed in the body of the request and the query string
// is blank.
const canonical_querystring = ''
//## I am doing step 6 first so that I can include the payload hash in the cannonical header, per .html
// Step 6: Create payload hash. In this example, the payload (body of
// the request) contains the request parameters.
const payload_hash = createHash('sha256').update(request_parameters).digest('hex');
// Step 4: Create the canonical headers. Header names must be trimmed
// and lowercase, and sorted in code point order from low to high.
// Note that there is a trailing \n.
const canonical_headers = 'host:' + host + '\n' + 'x-amz-content-sha256:' + payload_hash + '\n' + 'x-amz-date:' + amz_date + '\n'
// Step 5: Create the list of signed headers. This lists the headers
// in the canonical_headers list, delimited with ";" and in alpha order.
// Note: The request can include any headers; canonical_headers and
// signed_headers include those that you want to be included in the
// hash of the request. "Host" and "x-amz-date" are always required.
const signed_headers = 'host;x-amz-content-sha256;x-amz-date'
// Step 7: Combine elements to create canonical request
const canonical_request = method + '\n' + canonical_uri + '\n' + canonical_querystring + '\n' + canonical_headers + '\n' + signed_headers + '\n' + payload_hash
// ************* TASK 2: CREATE THE STRING TO SIGN*************
// Match the algorithm to the hashing algorithm you use, either SHA-1 or
// SHA-256 (remended)
const algorithm = 'AWS4-HMAC-SHA256'
const credential_scope = date_stamp + '/' + region + '/' + service + '/' + 'aws4_request'
const string_to_sign = algorithm + '\n' + amz_date + '\n' + credential_scope + '\n' + createHash('sha256').update(canonical_request).digest("utf-8")
// ************* TASK 3: CALCULATE THE SIGNATURE *************
// Create the signing key using the function defined above.
const signing_key = getSignatureKey(secret_key, date_stamp, region, service)
// Sign the string_to_sign using the signing_key
const signature = "" + createHmac('sha256', signing_key).update(string_to_sign).digest('hex');
// ************* TASK 4: ADD SIGNING INFORMATION TO THE REQUEST *************
// Put the signature information in a header named Authorization.
const authorization_header = algorithm + ' ' + 'Credential=' + access_key + '/' + credential_scope + ', ' + 'SignedHeaders=' + signed_headers + ', ' + 'Signature=' + signature
// For DynamoDB, the request can include any headers, but MUST include "host", "x-amz-date",
// "x-amz-target", "content-type", and "Authorization". Except for the authorization
// header, the headers must be included in the canonical_headers and signed_headers values, as
// noted earlier. Order here is not significant.
const headers = {
'X-Amz-Content-Sha256':payload_hash,
'X-Amz-Date':amz_date,
'Authorization':authorization_header,
'Content-Type':content_type
}
// ************* SEND THE REQUEST *************
var response = await axios({
method: method,
baseURL: base + host,
url: canonical_uri,
data:request_parameters,
headers: headers,
});
console.log(response)
}
However, in the browser, this method fails with 403: MissingAuthenticationToken
. Here is what the request looks like:
POST
HEADERS:
Host: fjakldfda.execute-api.us-east-2.amazonaws
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:99.0) Gecko/20100101 Firefox/99.0
Accept: application/json, text/plain, */*
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate, br
Content-Type: application/json
X-Amz-Content-Sha256: <HASH>
X-Amz-Date: 20220805T035948Z
Authorization: AWS4-HMAC-SHA256 Credential=<ACCESS_KEY>/20220805/us-east-2/execute-api/aws4_request, SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature=<SIGNATURE>
Content-Length: 34
Origin: http://localhost:3000
Connection: keep-alive
Referer: http://localhost:3000/
Sec-Fetch-Dest: empty
Sec-Fetch-Mode: cors
Sec-Fetch-Site: cross-site
DATA:
{
"date":"today",
"content":"hello"
}'
I can get the request to work in postman, however. Here is the cURL:
curl --location --request POST '' \
--header 'X-Amz-Content-Sha256: <HASH>' \
--header 'X-Amz-Date: 20220805T035948Z' \
--header 'Authorization: AWS4-HMAC-SHA256 Credential=<ACCESS_KEY>/20220805/us-east-2/execute-api/aws4_request, SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature=<SIGNATURE>' \
--header 'Content-Type: application/json' \
--data-raw '{
"date":"today",
"content":"hello"
}'
All of the headers match, except for the hashes, so I am forced to assume that there is an error in the way I hash/encode the signed headers.
TL;DR How do you correctly hash/sign a post request with AWS4?
For most of the code, I followed along with this python example provided by AWS, making the necessary changes for JS/node.
import axios from 'axios'
import {createHash, createHmac} from 'crypto'
import moment from 'moment'
async function send() {
const method = 'POST';
const service = 'execute-api';
const host = 'fjakldfda.execute-api.us-east-2.amazonaws.';
const region = 'us-east-2';
const base = "https://"
// POST requests use a content type header. For DynamoDB,
// the content is JSON.
const content_type = 'application/json';
// DynamoDB requires an x-amz-target header that has this format:
// DynamoDB_<API version>.<operationName>
//##...but I don't! Blank this out.
const amz_target = '';
// Request parameters for CreateTable--passed in a JSON block.
var request_parameters = `{"date":"today","content":"hello"}`
// Key derivation functions. See:
// http://docs.aws.amazon./general/latest/gr/signature-v4-examples.html//signature-v4-examples-python
function sign(key, msg) {
return createHmac('sha256', key).update(msg).digest('utf-8')
}
function getSignatureKey(key, date_stamp, regionName, serviceName) {
var kDate = sign(('AWS4' + key), date_stamp)
var kRegion = sign(kDate, regionName)
var kService = sign(kRegion, serviceName)
var kSigning = sign(kService, 'aws4_request')
return kSigning
}
// Read AWS access key from env. variables or configuration file. Best practice is NOT
// to embed credentials in code.
const access_key = "<CRED>"
const secret_key = "<CRED>"
// Create a date for headers and the credential string
const amz_date = moment().utc().format("yyyyMMDDThhmmss") + "Z"
const date_stamp = moment().utc().format("yyyyMMDD")
// ************* TASK 1: CREATE A CANONICAL REQUEST *************
// http://docs.aws.amazon./general/latest/gr/sigv4-create-canonical-request.html
// Step 1 is to define the verb (GET, POST, etc.)--already done.
// Step 2: Create canonical URI--the part of the URI from domain to query
// string (use '/' if no path)
const canonical_uri = '/prod/events/add-event'
//// Step 3: Create the canonical query string. In this example, request
// parameters are passed in the body of the request and the query string
// is blank.
const canonical_querystring = ''
//## I am doing step 6 first so that I can include the payload hash in the cannonical header, per https://docs.aws.amazon./AmazonS3/latest/API/sig-v4-header-based-auth.html
// Step 6: Create payload hash. In this example, the payload (body of
// the request) contains the request parameters.
const payload_hash = createHash('sha256').update(request_parameters).digest('hex');
// Step 4: Create the canonical headers. Header names must be trimmed
// and lowercase, and sorted in code point order from low to high.
// Note that there is a trailing \n.
const canonical_headers = 'host:' + host + '\n' + 'x-amz-content-sha256:' + payload_hash + '\n' + 'x-amz-date:' + amz_date + '\n'
// Step 5: Create the list of signed headers. This lists the headers
// in the canonical_headers list, delimited with ";" and in alpha order.
// Note: The request can include any headers; canonical_headers and
// signed_headers include those that you want to be included in the
// hash of the request. "Host" and "x-amz-date" are always required.
const signed_headers = 'host;x-amz-content-sha256;x-amz-date'
// Step 7: Combine elements to create canonical request
const canonical_request = method + '\n' + canonical_uri + '\n' + canonical_querystring + '\n' + canonical_headers + '\n' + signed_headers + '\n' + payload_hash
// ************* TASK 2: CREATE THE STRING TO SIGN*************
// Match the algorithm to the hashing algorithm you use, either SHA-1 or
// SHA-256 (remended)
const algorithm = 'AWS4-HMAC-SHA256'
const credential_scope = date_stamp + '/' + region + '/' + service + '/' + 'aws4_request'
const string_to_sign = algorithm + '\n' + amz_date + '\n' + credential_scope + '\n' + createHash('sha256').update(canonical_request).digest("utf-8")
// ************* TASK 3: CALCULATE THE SIGNATURE *************
// Create the signing key using the function defined above.
const signing_key = getSignatureKey(secret_key, date_stamp, region, service)
// Sign the string_to_sign using the signing_key
const signature = "" + createHmac('sha256', signing_key).update(string_to_sign).digest('hex');
// ************* TASK 4: ADD SIGNING INFORMATION TO THE REQUEST *************
// Put the signature information in a header named Authorization.
const authorization_header = algorithm + ' ' + 'Credential=' + access_key + '/' + credential_scope + ', ' + 'SignedHeaders=' + signed_headers + ', ' + 'Signature=' + signature
// For DynamoDB, the request can include any headers, but MUST include "host", "x-amz-date",
// "x-amz-target", "content-type", and "Authorization". Except for the authorization
// header, the headers must be included in the canonical_headers and signed_headers values, as
// noted earlier. Order here is not significant.
const headers = {
'X-Amz-Content-Sha256':payload_hash,
'X-Amz-Date':amz_date,
'Authorization':authorization_header,
'Content-Type':content_type
}
// ************* SEND THE REQUEST *************
var response = await axios({
method: method,
baseURL: base + host,
url: canonical_uri,
data:request_parameters,
headers: headers,
});
console.log(response)
}
However, in the browser, this method fails with 403: MissingAuthenticationToken
. Here is what the request looks like:
POST https://fjakldfda.execute-api.us-east-2.amazonaws./prod/events/add-event
HEADERS:
Host: fjakldfda.execute-api.us-east-2.amazonaws.
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:99.0) Gecko/20100101 Firefox/99.0
Accept: application/json, text/plain, */*
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate, br
Content-Type: application/json
X-Amz-Content-Sha256: <HASH>
X-Amz-Date: 20220805T035948Z
Authorization: AWS4-HMAC-SHA256 Credential=<ACCESS_KEY>/20220805/us-east-2/execute-api/aws4_request, SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature=<SIGNATURE>
Content-Length: 34
Origin: http://localhost:3000
Connection: keep-alive
Referer: http://localhost:3000/
Sec-Fetch-Dest: empty
Sec-Fetch-Mode: cors
Sec-Fetch-Site: cross-site
DATA:
{
"date":"today",
"content":"hello"
}'
I can get the request to work in postman, however. Here is the cURL:
curl --location --request POST 'https://fjakldfda.execute-api.us-east-2.amazonaws./prod/events/add-event' \
--header 'X-Amz-Content-Sha256: <HASH>' \
--header 'X-Amz-Date: 20220805T035948Z' \
--header 'Authorization: AWS4-HMAC-SHA256 Credential=<ACCESS_KEY>/20220805/us-east-2/execute-api/aws4_request, SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature=<SIGNATURE>' \
--header 'Content-Type: application/json' \
--data-raw '{
"date":"today",
"content":"hello"
}'
All of the headers match, except for the hashes, so I am forced to assume that there is an error in the way I hash/encode the signed headers.
TL;DR How do you correctly hash/sign a post request with AWS4?
Share Improve this question edited Aug 6, 2022 at 7:36 JeremiahDuane asked Aug 5, 2022 at 4:43 JeremiahDuaneJeremiahDuane 4682 gold badges5 silver badges17 bronze badges 6-
Are you sure the request you're looking at in the browser is for the
POST
and not a pre-flightOPTIONS
? – Phil Commented Aug 5, 2022 at 4:50 - @Phil Yes. I do get an OPTIONS in the browser directly after, but what I have transcribed here is from the POST – JeremiahDuane Commented Aug 5, 2022 at 4:53
-
Cool, just checking. FYI the
OPTIONS
should be before, not after. That's why it's called a pre-flight request – Phil Commented Aug 5, 2022 at 4:54 - @Phil Ah, you are correct. When you repeatedly spam the button, you see it before AND after :-D – JeremiahDuane Commented Aug 5, 2022 at 4:55
- You've indicated DynamoDB in a few places so I'm assuming you are using Amazon API Gateway as a proxy for DynamoDB. Given that you have IAM user creds, by the look of things, any reason to prefer API Gateway proxy over simply using the DynamoDB module from the AWS SDK v3 to talk directly to DynamoDB? – jarmod Commented Aug 6, 2022 at 1:29
2 Answers
Reset to default 5Finally got it. I switched to using crypto-js
and it worked.
import moment from 'moment'
import crypto from 'crypto-js'
import axios, { CancelToken } from "axios"
async function send() {
const access_key = "XXXX"
const secret_key = "XXXX"
const method = 'POST';
const service = 'execute-api';
const host = 'dfadfadfdafda.execute-api.us-east-2.amazonaws.';
const region = 'us-east-2';
const base = "https://"
const content_type = 'application/json';
// DynamoDB requires an x-amz-target header that has this format:
// DynamoDB_<API version>.<operationName>
const amz_target = '';
function getSignatureKey(key, dateStamp, regionName, serviceName) {
var kDate = crypto.HmacSHA256(dateStamp, "AWS4" + key);
var kRegion = crypto.HmacSHA256(regionName, kDate);
var kService = crypto.HmacSHA256(serviceName, kRegion);
var kSigning = crypto.HmacSHA256("aws4_request", kService);
return kSigning;
}
// ************* TASK 1: CREATE A CANONICAL REQUEST *************
// http://docs.aws.amazon./general/latest/gr/sigv4-create-canonical-request.html
// Step 1 is to define the verb (GET, POST, etc.)--already done.
// Step 2: Create canonical URI--the part of the URI from domain to query
// string (use '/' if no path)
// Create a date for headers and the credential string
const amz_date = moment().utc().format("yyyyMMDDTHHmmss\\Z")
const date_stamp = moment().utc().format("yyyyMMDD")
//// Step 3: Create the canonical query string. In this example, request
// parameters are passed in the body of the request and the query string
// is blank.
const canonical_querystring = ''
//## DOing step 6 first so that I can include the payload hash in the cannonical header, per https://docs.aws.amazon./AmazonS3/latest/API/sig-v4-header-based-auth.html
// Step 6: Create payload hash. In this example, the payload (body of
// the request) contains the request parameters.
//const payload_hash = hashlib.sha256(request_parameters.encode('utf-8')).hexdigest()
const payload_hash = crypto.SHA256(request_parameters);
// Step 4: Create the canonical headers. Header names must be trimmed
// and lowercase, and sorted in code point order from low to high.
// Note that there is a trailing \n.
const canonical_headers = 'host:' + host + '\n' + 'x-amz-content-sha256:' + payload_hash + '\n' + 'x-amz-date:' + amz_date + '\n'
// Step 5: Create the list of signed headers. This lists the headers
// in the canonical_headers list, delimited with ";" and in alpha order.
// Note: The request can include any headers; canonical_headers and
// signed_headers include those that you want to be included in the
// hash of the request. "Host" and "x-amz-date" are always required.
const signed_headers = 'host;x-amz-content-sha256;x-amz-date'
// Step 7: Combine elements to create canonical request
const canonical_request = method + '\n' + canonical_uri + '\n' + canonical_querystring + '\n' + canonical_headers + '\n' + signed_headers + '\n' + payload_hash
// ************* TASK 2: CREATE THE STRING TO SIGN*************
// Match the algorithm to the hashing algorithm you use, either SHA-1 or
// SHA-256 (remended)
const algorithm = 'AWS4-HMAC-SHA256'
const credential_scope = date_stamp + '/' + region + '/' + service + '/' + 'aws4_request'
const string_to_sign = algorithm + '\n' + amz_date + '\n' + credential_scope + '\n' + crypto.SHA256(canonical_request);
// ************* TASK 3: CALCULATE THE SIGNATURE *************
// Create the signing key using the function defined above.
const signing_key = getSignatureKey(secret_key, date_stamp, region, service)
// Sign the string_to_sign using the signing_key
const signature = crypto.HmacSHA256(string_to_sign, signing_key);
// ************* TASK 4: ADD SIGNING INFORMATION TO THE REQUEST *************
// Put the signature information in a header named Authorization.
const authorization_header = algorithm + ' ' + 'Credential=' + access_key + '/' + credential_scope + ', ' + 'SignedHeaders=' + signed_headers + ', ' + 'Signature=' + signature
// For DynamoDB, the request can include any headers, but MUST include "host", "x-amz-date",
// "x-amz-target", "content-type", and "Authorization". Except for the authorization
// header, the headers must be included in the canonical_headers and signed_headers values, as
// noted earlier. Order here is not significant.
const headers = {
'X-Amz-Content-Sha256':payload_hash,
'X-Amz-Date':amz_date,
//'X-Amz-Target':amz_target,
'Authorization':authorization_header,
'Content-Type':content_type
}
// ************* SEND THE REQUEST *************
var response = await axios({
method: method,
baseURL: base + host,
url: canonical_uri,
data:request_parameters,
headers: headers,
});
console.log(response)
}
I also faced issue and made it work using the built in crypto
module. The solution was to use Buffer.from
to keep the key as a binary
const crypto = require('crypto');
const {
method = 'GET',
uri,
service,
region ,
keyID,
secret,
algo = 'AWS4-HMAC-SHA256',
body = '',
headers,
jsonQuery, // a JSON object representing the query parameter of the call
} = inputs
const host = `${service}.${region}.amazonaws.`;
const now = new Date();
const shortdate = now.toISOString().slice(0, 10).replace(/-/g, '');
//nullify the seconds to make it easier to debug
const fulldate = now.toISOString().slice(0, -7).replace(/[-:]/g, '') + '00Z';
const file_sha256 = crypto.createHash('sha256').update(body ?? query).digest('hex');
const jsonHeaders = {
host: host,
'x-amz-date': fulldate,
...(headers === Object(headers) ? headers : JSON.parse(headers || '{}')),
...(service === 's3' || body ? { 'x-amz-content-sha256': file_sha256 } : {}),
};
const canonicalURI = `${uri?.[0] !== '/' ? '/' : ''}${uri || ''}`;
const canonicalQuery = Object.keys(jsonQuery).length === 0 ? '' : Object.entries(jsonQuery).sort((a, b) => encodeURIComponent(a[0]).localeCompare(encodeURIComponent(b[0]))).reduce((acc, [key, value]) => {
const sep = acc ? '&' : '';
return `${acc}${sep}${encodeURIComponent(key)}=${encodeURIComponent(
value === Object(value) ? JSON.stringify(value).replace(/\n/g,'') : value
)}`
},"");
const canonicalHeaders = Object.entries(jsonHeaders).sort((a, b) => a[0].localeCompare(b[0])).reduce((acc, [key, value]) => {
const sep = acc ? '\n' : '';
return `${acc}${sep}${key.toLowerCase()}:${value.trim()}`
},"") + "\n";
const signedHeaders = Object.entries(jsonHeaders).sort((a, b) => a[0].localeCompare(b[0])).reduce((acc, [key, value]) => {
const sep = acc ? ';' : '';
return `${acc}${sep}${key.toLowerCase()}`
},"");
const canonicalRequest = [method, canonicalURI, canonicalQuery, canonicalHeaders, signedHeaders, file_sha256].join('\n');
const canonReqSha = crypto.createHash('sha256').update(canonicalRequest).digest('hex');
const stringToSign = `${algo}
${fulldate}
${shortdate}/${region}/${service}/aws4_request
${canonReqSha}`;
function hmac_sha256(key, data) {
return crypto.createHmac('sha256', key).update(data).digest('hex');
}
const secretBuffer = Buffer.from("AWS4" + secret, 'utf8');
const k_date = hmac_sha256(secretBuffer, shortdate);
const k_region = hmac_sha256(Buffer.from(k_date, 'hex'), region);
const k_service = hmac_sha256(Buffer.from(k_region, 'hex'), service);
const k_signing = hmac_sha256(Buffer.from(k_service, 'hex'), "aws4_request");
const signature = hmac_sha256(Buffer.from(k_signing, 'hex'), stringToSign);
const authorization = `${algo} Credential=${keyID}/${shortdate}/${region}/${service}/aws4_request, SignedHeaders=${signedHeaders}, Signature=${signature}`;
return {
method,
url: `https://${host}${canonicalURI}${Object.keys(jsonQuery).length === 0 ? '' : '?'}${canonicalQuery}`,
authorization,
headers: {
...jsonHeaders,
authorization,
},
body
}