Making Cloudfront Signed URLs work with response-content-disposition

πŸ“… 2022-11-28

βŒ› 1378 days ago

πŸ“– 2 min read


With v3.215.0 of the @aws-sdk/cloudfront-signer package, adding a response-content-disposition query parameter to the URL being sent to the getSignedUrl function changes the URL after signing it. This mismatch can cause an Access Denied error on the output Cloudfront URL.

These examples use getSignedUrl from @aws-sdk/cloudfront-signer. The config object contains your CloudFront key-pair ID and private signing key.

const disposition = 'attachment;filename="test.txt"';

const signedUrl = getSignedUrl({
	keyPairId: config.cloudfrontKeyPairId,
	privateKey: config.cloudfrontSigningKey,
	url: `https://d1x2y3z4.cloudfront.net/test.txt?response-content-disposition=${encodeURIComponent(
		disposition,
	)}`,
	dateLessThan: new Date(Date.now() + 1000 * 60 * 60 * 24 * 7).toISOString(),
});

console.log(signedUrl);
// The returned query contains attachment;filename="test.txt" instead of its encoded form.

The above code will result in an Access Denied error when the URL is accessed:

<Error>
  <Code>AccessDenied</Code>
  <Message>Access denied</Message>
</Error>

Evidently, the issue happens when the encoded URL (using encodeURIComponent) is reverted by the getSignedUrl function.

From the source, it seems that the input url parameter is fed into the @aws-sdk/url-parser package, which is presumably where the encoded URL is being lost in translation.

import { parseUrl } from "@aws-sdk/url-parser";

...

/**
 * Creates a signed URL string using a canned or custom policy.
 * @returns the input URL with signature attached as query parameters.
 */
export function getSignedUrl({
  dateLessThan,
  dateGreaterThan,
  url,
  keyPairId,
  privateKey,
  ipAddress,
  policy,
}: CloudfrontSignInput): string {
  const parsedUrl = parseUrl(url);
	...
}

The Workaround

The workaround is to manually set the query parameter after parsing the signed URL:

const disposition = 'attachment;filename="test.txt"';

const url = new URL(`test.txt`, `https://d1x2y3z4.cloudfront.net`);
url.searchParams.append("response-content-disposition", disposition);

const signedUrl = getSignedUrl({
	url: url.href,
	keyPairId: config.cloudfrontKeyPairId,
	privateKey: config.cloudfrontSigningKey,
	dateLessThan: new Date(Date.now() + 1000 * 60 * 60 * 24 * 7).toISOString(),
});

const fixedSignedUrl = new URL(signedUrl);
fixedSignedUrl.searchParams.set("response-content-disposition", disposition);
return fixedSignedUrl.href;

September 2026 correction: The examples use v3.215.0, the last version of this package published before this post. The previously listed v3.219.0 was not a published package version. I corrected the variables and expiration values, then checked both signatures locally with a temporary key. This check covers the encoding mismatch and the workaround, not a live CloudFront request or current SDK behavior.

Which works out just fine! πŸŽ‰

From the comments on this GitHub issue, a β€œfix” doesn’t seem to be in the works. I ended up losing many a few hours trying to figure out what was going on. Hoping that this helps out someone else.