How to use CloudFront signed cookies to serve MPD or HLS videos

Protected content without changing the URLs

Author's image
Tamás Sallai
3 mins

Signed URLs

Signed URLs is a mechanism to securely give access to protected content. It works by the backend generating a signature that the clients then can use directly with S3 or CloudFront to get the content. It's the primary way to offer downloads and uploads in serverless applications.

For example, if an image is stored at images/abc.jpg then a signed URL for it would be images/abc.jpg?x-id=GetObject&....

Notice that URL for the file is changed. This is usually not a problem, as when the user clicks the "download" button, there is no expectation about where the file is downloaded from so the backend is free to return a signed URL.

Segmented video formats

But in some cases, the client expects the file to have a specific URL. One of the most common examples for this is segmented video files, such as HLS or MPD. Here, the video stream is broken up to segments and a manifest file defines where the individual files can be found.

For example, an MPD manifest looks like this:

<Representation
	id="0"
	mimeType="video/mp4"
	codecs="avc1.4d401f"
	bandwidth="800000"
	width="1280"
	height="720"
	sar="1:1"
>
	<SegmentTemplate
		timescale="15360"
		initialization="init-stream$RepresentationID$.m4s"
		media="chunk-stream$RepresentationID$-$Number%05d$.m4s"
		startNumber="1"
	>
		<SegmentTimeline>
			<S t="0" d="122880" />
			<S d="30720" />
		</SegmentTimeline>
	</SegmentTemplate>
</Representation>

When the client reads this and plays the video, it knows how to download the segments:

  • chunk-stream0-00001.m4s
  • chunk-stream0-00002.m4s
  • chunk-stream0-00003.m4s
  • ...

But then it does not work with signed URLs anymore as the client can not possibly calculate the signature for each file.

This is where signed cookies are useful.

CloudFront signed cookies

Signed cookies is another mechanism to give controlled access to protected files. Instead of modifying a URL, the backend returns a set of cookies for the client. By the standard, these cookies are attached to the requests automatically by the browser, which means there is no change needed on the client.

An example set of signed cookies:

{
	"CloudFront-Key-Pair-Id": "KJX6ADYM9FBCS",
	"CloudFront-Signature": "kAqF32fiDKmOUpDPUNQ...",
	"CloudFront-Policy": "eyJTdGF0ZW1lbnQiOlt7IlJlc29..."
}

The signature can contain wildcards for signed cookies, in practice that means that it's possible to sign them for all files under a directory. This gives an easy-to-use structure where each video can be stored in a folder and the signature can give access to a specific one the client wants to play.

For example, the S3 bucket can contain two videos:

bunny/bunny.mpd
bunny/chunk-stream0-00001.m4s
bunny/chunk-stream0-00002.m4s
bunny/chunk-stream1-00001.m4s
bunny/chunk-stream1-00002.m4s
bunny/init-stream0.m4s
bunny/init-stream1.m4s

sintel/sintel.mpd
sintel/chunk-stream0-00001.m4s
sintel/chunk-stream0-00002.m4s
sintel/chunk-stream1-00001.m4s
sintel/chunk-stream1-00002.m4s
sintel/init-stream0.m4s
sintel/init-stream1.m4s

This S3 bucket is then used as an origin for the CloudFront distribution and mapped to a path, let's say /videos/*

During signing, the backend generates the cookies for a specific folder under this path:

import {getSignedCookies} from "@aws-sdk/cloudfront-signer";

return getSignedCookies({
	keyPairId: process.env.KEYPAIR_ID,
	privateKey: (await getCfPrivateKey()).Parameter.Value,
	policy: JSON.stringify({
		Statement: [
			{
				Resource:
	`https://${process.env.DISTRIBUTION_DOMAIN}/videos/${video}/*`,
				Condition: {
					DateLessThan: {
						"AWS:EpochTime":
							Math.round(
								new Date(
									new Date().getTime() + 60 * 60 * 1000
								).getTime() / 1000
							),
					}
				}
			}
		]
	})
});

The only thing left is to set the cookies in the HTTP response:

return {
	statusCode: 307,
	cookies: Object.entries(signedCookies).map(([name, value]) => {
		return [
			`${name}=${value}`,
			"HttpOnly",
			`Path=/videos/${videoName}`,
			"SameSite=Strict",
			"Secure",
		].join("; ");
	}),
	headers: {
		Location: `/videos/${videoName}/${videoName}.mpd`,
	},
};

Notice the Path here: since the name of the cookies are fixed, setting them on the / would overwrite earlier cookies. That could cause problems when the client plays multiple videos in parallel, such as using several tabs. The best practice here is to use the most specific path for the cookies.

January 9, 2024
In this article