AWS for M&E Blog

Secure content using CloudFront Functions

To secure streaming video content from Amazon CloudFront, a fast content delivery network (CDN) service on Amazon Web Services (AWS), two methods are available: signed cookies or signed URLs. Customers can choose to use either one or both, depending on the use case.

For example, a customer might choose signed cookies to authorize the resource URL with a wildcard character providing authorization to all the content, including main manifest, submanifest, and transport stream segments for secure video playback in HTTP Live Streaming (HLS).

On the other hand, if a client does not support cookies or has disabled cookies, customers rely on signed URLs, which demand the signing of each video segment URL to be authorized. This requires HLS manifest manipulation to include a security signature in each URL during video playback, causing implementation overhead and performance degradation.

In addition, customers can also create custom security workflows using CloudFront Functions—a native feature of Amazon CloudFront that lets you write lightweight functions in JavaScript for high-scale, latency-sensitive CDN customizations—to validate and prevent unauthorized playback of video content. Unlike the signed cookies and signed URLs methods, which use limited attributes like expires, resource, and client internet protocol (IP), the custom workflow using CloudFront Functions provides facility to include preferred properties (for example, user agent) in the security signature to strengthen the overall security of the video content during delivery. It also unifies the security approach, negating the selection of suitable said methods depending on the client capability.

In this post, we take you through the steps to build a custom security workflow using CloudFront Functions to prevent video content from unauthorized external threats.

High-level schematic view

High level architecture

Directions

Create a custom-authorizer Lambda function using AWS Lambda

For this step, you’ll use the console for AWS Lambda, a serverless compute service that lets you run code without provisioning or managing servers, creating workload-aware cluster scaling logic, maintaining event integrations, or managing runtimes.

1. Open the AWS Lambda Console.

2. In the Navigation Pane, select Functions.

3. Select Create function.

4. In Basic Information, enter Function name as “custom-authorizer”.

5. Select js 14.x as a Runtime.

6. Select Create function.

7. In the Code Tab, double-click js.

8. Copy the following code snippet and replace the existing code displayed in the editor section.

Code snippet for custom-authorizer Lambda:

1.	JavaScript
2.	const AWS = require('aws-sdk');
3.	const functionName = process.env.AWS_LAMBDA_FUNCTION_NAME;
4.	const secret = process.env['SECRET'];
5.	let decrypted_secret;
6.	 
7.	function inHouseAuthCheck(event) {
8.	    //Perform your in house authorization
9.	    return true;
10.	}
11.	 
12.	function processEvent(event) {
13.	    console.log(event);
14.	    //Perform in house auth continue only if it succeeds.
15.	    if (!inHouseAuthCheck(event)) {
16.	        const response = {
17.	            statusCode: 500,
18.	            body: JSON.stringify({"Error": "In House Authorization Failed."}),
19.	        };
20.	        return response;
21.	    }
22.	    //Parse the body for the information; say in this case PlaybackURL.
23.	    var url = event['body-json']['playback_url'];
24.	    var expires_duration = 86400;
25.	    var d = new Date();
26.	    var expires = Math.floor(d.getTime() / 1000) + expires_duration;
27.	    var url = new URL(url);
28.	    var hostname = url.hostname;
29.	    var path = url.pathname;
30.	    //Including wildcard to support ABR playback
31.	    var manifestname = path.split("/").pop();
32.	    path = path.replace(manifestname, "*");
33.	 
34.	    //Stream key Generation using properties (Client IP, URL path, Hostname, Expires and User Agent)
35.	    var client_ip = event["params"]["header"]["X-Forwarded-For"];
36.	    var user_agent =  event["params"]["header"]["User-Agent"];
37.	    var customdata = {"client_ip": client_ip, "path": path, "hostname": hostname, "expires": expires, "user_agent": user_agent};
38.	    console.log("Custom Data => " + JSON.stringify(customdata));
39.	    var crypto = require('crypto');
40.	    var secret = decrypted_secret;
41.	    var stream_hash = crypto.createHmac('sha256', secret);
42.	    var stream_key = stream_hash.update(JSON.stringify(customdata)).digest('base64');
43.	    console.log("Stream Key => " + stream_key);
44.	 
45.	    //Stream policy Generation using reconstructed URL Path and Expires
46.	    var stream_policy_json = {
47.	        "url": path,
48.	        "expires": expires
49.	    };
50.	    var stream_policy_str = new Buffer(JSON.stringify(stream_policy_json));
51.	    var stream_policy = stream_policy_str.toString('base64');
52.	    console.log("Stream Policy =>" + stream_policy);
53.	 
54.	    //Stream Key response
55.	    const response = {
56.	        statusCode: 200,
57.	        body: {"stream_key": stream_key, "stream_policy": stream_policy},
58.	    };
59.	    return response;
60.	};
61.	 
62.	exports.handler = async (event) => {
63.	    if (!decrypted_secret) {
64.	        // Decrypt code should run once and variables stored outside of the
65.	        // function handler so that these are decrypted once per container
66.	        const kms = new AWS.KMS();
67.	        try {
68.	            const req = {
69.	                CiphertextBlob: Buffer.from(secret, 'base64'),
70.	                EncryptionContext: {LambdaFunctionName: functionName},
71.	            };
72.	            const data = await kms.decrypt(req).promise();
73.	            decrypted_secret = data.Plaintext.toString('ascii');
74.	        } catch (err) {
75.	            console.log('Decrypt error:', err);
76.	            throw err;
77.	        }
78.	    }
79.	    return processEvent(event);
80.	}; 

9. Select Deploy

10. Choose Configuration, and then choose Environment

11. Under Environment variables, choose Edit.

12. Choose Add environment variable.

13. Enter a key named SECRET and value of your choice. The key value should be a randomized string of at least 32 characters that is known only to your application. Securely store the key value as it will need to be included in your CloudFront Function later.

14. Expand Encryption configuration.

15. Choose Enable helpers for encryption in transit.

16. Choose Encrypt next to a variable to encrypt its value.

17. Choose Save.

18. Include the following additional policy against the AWS Lambda role to allow decryption using AWS Key Management Service (AWS KMS) (a secure and resilient service for creating and managing cryptographic keys):

JSON 
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "VisualEditor0",
            "Effect": "Allow",
            "Action": "kms:Decrypt",
            "Resource": "<<INSERT THE ARN OF YOUR KMS KEY FOR LAMBDA HERE>>"
        }
    ]

Associate a custom-authorizer Lambda function using Amazon API Gateway

For this step, you’ll use the console for Amazon API Gateway, a fully managed service that makes it easy for developers to create, publish, maintain, monitor, and secure APIs at any scale.

1. Create an API gateway

a) Open the Amazon API Gateway Console.

b) Choose REST API.

c) Choose New API under Create new API.

d) Enter API name—for example, “secure-video”.

e) Enter Description of your choice.

f) Choose an Endpoint Type—for example, Regional.

g) Choose Create API.

2. Create Resource

a) Select Resources in the Amazon API Gateway navigation pane.

b) Select Create Resource under Actions.

c) Enter the Resource Name—for example, “getStreamKey”.

d) Choose Create Resource.

3. Create Method and associate the Lambda function

a) Select /getStreamKey resource.

b) Select Create Method under Actions.

c) Select GET method using the dropdown list option under Resource.

d) Select tick icon.

e) Select Integration type as Lambda Function under Setup.

f) Select custom-authorize against the Lambda Function.

g) Choose Save.

h) Select Mapping Templates section in Integration Request.

i) Select Add mapping template and use application/json in Content-Type section.

j) Select template Method Request passthrough from the template dropdown.

k) Choose Save.

4. Deploy API

a) Select Deploy API under Actions.

b) Select New Stage option against Deployment stage.

c) Enter stage name of your choice—for example, “secure-video”.

d) Choose Deploy.

Generate a secure stream key

When an authorized user selects video content for playback, the playback client requests generation of a secure stream key to activate playback. It delegates the request to a custom-authorizer Lambda function through the Amazon API Gateway, where a stream key is generated following specific recommended properties. In addition, you can also include any additional specific properties of your choice to strengthen security for video playback—for example, an auth token generated for subscribed users, a query string, a distribution identifier, and more.

1. Client IP

a) Client IP is extracted from the HTTP request header field “X-FORWARDED-FOR,” which is a common method to identify the playback request–originating IP address.

b) Including this property means that only the requested client has access to the video content.

2. User agent

a) User agent is extracted from the HTTP request header field “User-Agent,” which identifies the application, operating system, vendor, and version of the requesting client.

b) Including this property protects against the video playing in a device with a different user agent.

3. Resource URL path

a) Supply the resource URL path that needs to be authorized using the stream key.

This URL can be either a complete URL path pointing to the asset if you want progressive playback, or a URL path suffixed with (*) to allow all the resources for HTTP streaming (like manifest, submanifest, segments, and more).

b) Including this property means that only the specific set of resources is authorized using the stream key.

4. Expires

a) This determines the expiration of the stream key.

This is the time needed to determine how long the stream key will be valid.

b) Including this property means that the stream key generated is valid only for a certain period and it cannot be used after expiry.

5. Hostname

a) The hostname is extracted from the “host” attribute in the request header that specifies which server the request is sent to.

b) This property makes sure that the content plays from the intended origin/hostname.

Construct a JSON code snippet including all the properties as shown in the following (you can also include protocol, port, and more):

81.	 //Stream key Generation using properties (Client IP, URL path, Hostname, Expires and User Agent)
82.	    var client_ip = event["params"]["header"]["X-Forwarded-For"];
83.	    var user_agent =  event["params"]["header"]["User-Agent"];
84.	    var customdata = {"client_ip": client_ip, "path": path, "hostname": hostname, "expires": expires, "user_agent": user_agent};
85.	    console.log("Custom Data => " + JSON.stringify(customdata));

Trusted secret is fetched from AWS KMS as shown in the following:

86.	 if (!decrypted_secret) {
87.	        // Decrypt code should run once and variables stored outside of the
88.	        // function handler so that these are decrypted once per container
89.	        const kms = new AWS.KMS();
90.	        try {
91.	            const req = {
92.	                CiphertextBlob: Buffer.from(secret, 'base64'),
93.	                EncryptionContext: {LambdaFunctionName: functionName},
94.	            };
95.	            const data = await kms.decrypt(req).promise();
96.	            decrypted_secret = data.Plaintext.toString('ascii');
97.	        } catch (err) {
98.	            console.log('Decrypt error:', err);
99.	            throw err;
100.	        }
101.	    }

Create a secure stream key using the SHA256 algorithm:

102.	 var crypto = require('crypto');
103.	    var secret = decrypted_secret;
104.	    var stream_hash = crypto.createHmac('sha256', secret);
105.	    var stream_key = stream_hash.update(JSON.stringify(customdata)).digest('base64');
106.	    console.log("Stream Key => " + stream_key);

Any properties that the client is unaware of, and that the client used as part of stream key generation, are shared as stream policy in a simple JSON base64 encoded string. For example, if the expiry time determination or modified path is not known to the client, then it can be supplied as part of the stream policy to the client and is later used during validation.

107.	 //Stream policy Generation  using reconstructed URL Path  and Expires
108.	    var stream_policy_json = {
109.	        "url": path,
110.	        "expires": expires
111.	    };
112.	    var stream_policy_str = new Buffer(JSON.stringify(stream_policy_json));
113.	    var stream_policy = stream_policy_str.toString('base64');
114.	    console.log("Stream Policy =>" + stream_policy);

The stream key response from the custom-authorizer encapsulates the stream key and stream policy information in a JSON format:

115.	 //Stream Key response
116.	    const response = {
117.	        statusCode: 200,
118.	        body: {"stream_key": stream_key, "stream_policy": stream_policy},
119.	    };

Prepare the CloudFront Function

1. Create a simple CloudFront Function.

a) Open the Functions page in the Amazon CloudFront Console.

b) Choose Create function.

c) Enter function name—for example, “secure-stream”—and then choose Create function.

d) Copy the following code to the Function Code section:

          Warning: to use this function, you must put your secret key in the function code.

1.	 function handler(event) {
2.	    // NOTE: This example function is for a viewer request event trigger. 
3.	    // Choose viewer request for event trigger when you associate this function with a distribution. 
4.	    
5.	    //Importing Crypto for stream key validation
6.	    var crypto = require('crypto');
7.	    //Secret Key that is used; this is known to only custom authorizer and CloudFront function.
8.	    var secret = "<<INSERT THE KEY VALUE USED IN YOUR CUSTOM-AUTHORIZER LAMBDA FUNCTION HERE>>";  
9.	 
10.	    console.log(event.request)
11.	    var request = event.request;
12.	    var headers = request.headers;
13.	    var hostname = headers.host.value;
14.	    var user_agent = headers["user-agent"].value;
15.	    console.log("User Agent => "+ user_agent);
16.	    var uri = request.uri;
17.	    var client_ip = event.viewer.ip;
18.	 
19.	    
20.	    var stream_key = headers.stream_key.value;
21.	    var stream_policy = headers.stream_policy.value;
22.	    var stream_policy_decoded = String.bytesFrom(stream_policy, 'base64');
23.	    var stream_policy = JSON.parse(stream_policy_decoded);
24.	    var expires = stream_policy["expires"];
25.	    var path = stream_policy["url"];
26.	    
27.	    //Construct Stream key and validate
28.	    var customdata = {"client_ip": client_ip, "path": path, "hostname": hostname, "expires": expires, "user_agent": user_agent};
29.	    console.log(JSON.stringify(customdata));
30.	    var srv_hmac_key = crypto.createHmac('sha256', secret);
31.	    var srv_stream_key = srv_hmac_key.update(JSON.stringify(customdata)).digest('base64');
32.	    console.log(srv_stream_key);
33.	    if (stream_key != srv_stream_key) {
34.	        var response = {
35.	            statusCode: 500,
36.	            statusDescription: 'HMAC Key Mismatched!!',
37.	        }    
38.	        return response;
39.	        
40.	    }
41.	    //Add the true-client-ip header to the incoming request
42.	    request.headers['true-client-ip'] = {value: client_ip};
43.	    
44.	    var current_time = Math.round(Date.now() / 1000);
45.	    console.log("current time "+ current_time);
46.	    console.log("expires "+ expires);
47.	    if(current_time > expires) {
48.	        var response = {
49.	            statusCode: 500,
50.	            statusDescription: 'time expired!!',
51.	        }    
52.	        return response;
53.	    }
54.	    var path_check = path.replace("/", "\\/").replace("*", ".*");
55.	    var regex_str = new RegExp(path_check);
56.	    if(!regex_str.test(path)) {
57.	        var response = {
58.	            statusCode: 500,
59.	            statusDescription: 'URL pattern Mismatched!!',
60.	        }    
61.	        return response;
62.	    }
63.	    console.log("All Validation Succeeded!!. Good to Go!");
64.	    return event.request;
65.	}
66.	 

e) Test your CloudFront Function code.

2. Publish the CloudFront Function.

a) On the function page, choose the Publish tab, and then choose the Publish

b) When publication is successful, you’ll see a banner at the top of the page that says, “Successfully published secure-streams.”

3. Associate CloudFront Function.

a) Once the CloudFront Function successfully publishes, choose Associate tab to associate the function with Amazon CloudFront distribution.

      • For Distribution, choose a distribution to associate the function with.
      • For Event type, choose Viewer Request to run the function every time Amazon CloudFront receives a playback request.
      • For Cache behavior, choose a cache behavior to associate this function with (choose * for the default cache behavior).
        • For example, if you want to invoke the secure stream function only for the HLS manifest, choose *.m3u8 cache behavior.
      • Choose Add association.
      • In the Associate function to cache behavior pop-up window, choose Add association.

Associate the secure stream key during playback

The client used for playback should include the stream key as part of the header for every request involving video playback. Every video player (playback client) follows a different way to include headers during playback. Here are details for two examples:

  1. Please refer to this link on how custom headers are included in JW Player.
  2. Please refer to this link on how custom headers are included in ExoPlayer.

Validate the secure stream key

A CloudFront Function configured against the Amazon CloudFront distribution receives a stream key and stream policy supplied as request headers during the playback request. The CloudFront Function generates a stream key using the same properties and trusted shared secret used by the custom authorizer. In case that information is not available in the standard header, the CloudFront Function uses the stream policy to select the properties to align with the stream key generation process.

67.	JavaScript
68.	 var hostname = headers.host.value;
69.	 var user_agent = headers["user-agent"].value;
70.	 console.log("User Agent => "+ user_agent);
71.	 var uri = request.uri;
72.	 var client_ip = event.viewer.ip;
73.	 
74.	    
75.	 var stream_key = headers.stream_key.value;
76.	 var stream_policy = headers.stream_policy.value;
77.	 var stream_policy_decoded = String.bytesFrom(stream_policy, 'base64');
78.	 var stream_policy = JSON.parse(stream_policy_decoded);
79.	 var expires = stream_policy["expires"];
80.	 var path = stream_policy["url"];
81.	    
82.	 //Construct Stream key and validate
83.	 var customdata = {"client_ip": client_ip, "path": path, "hostname": hostname, "expires": expires, "user_agent": user_agent};
84.	 console.log(JSON.stringify(customdata));

Authorize the request to pass through only when the stream key generated by the CloudFront Function matches with the stream key supplied by the client. Return HTTP status code 500 if the stream key mismatches:

85.	 if (stream_key != srv_stream_key) {
86.	        var response = {
87.	            statusCode: 500,
88.	            statusDescription: 'HMAC Key Mismatched!!',
89.	        }    
90.	        return response;
91.	        
92.	    }

Test the flow

1. Get stream key:

curl --location --request GET 'https://<<API Gateway Endpoint>>/secure-video/getstreamkey' --header 'Content-Type: application/json' --data-raw '{ "playback_url": "https://d326kci5pvyw99.cloudfront.net/transcoded-out/high/1.m3u8"}'

2. Sample stream key response:

curl -v GET "https://d326kci5pvyw99.cloudfront.net/transcoded-out/high/1.m3u8" --header 'stream_key: fIKUV3bogTTWGFcRnKJDASTN2Eu5Mr+x9EaCRp43Wkc=' --header 'stream_policy: eyJ1cmwiOiIvdHJhbnNjb2RlZC1vdXQvaGlnaC8qIiwiZXhwaXJlcyI6MTYyODQ0Mzc1NH0='

3. Request for manifest using stream key and stream policy:

curl -v GET "https://d326kci5pvyw99.cloudfront.net/transcoded-out/high/1.m3u8" --header 'stream_key: fIKUV3bogTTWGFcRnKJDASTN2Eu5Mr+x9EaCRp43Wkc=' --header 'stream_policy: eyJ1cmwiOiIvdHJhbnNjb2RlZC1vdXQvaGlnaC8qIiwiZXhwaXJlcyI6MTYyODQ0Mzc1NH0='

1.	#EXTM3U
2.	#EXT-X-VERSION:3
3.	#EXT-X-INDEPENDENT-SEGMENTS
4.	#EXT-X-STREAM-INF:BANDWIDTH=2820417,AVERAGE-BANDWIDTH=2779558,CODECS="avc1.4d4028,mp4a.40.2",RESOLUTION=1920x1080,FRAME-RATE=25.000
5.	11080/1.m3u8
6.	#EXT-X-STREAM-INF:BANDWIDTH=1854376,AVERAGE-BANDWIDTH=1816108,CODECS="avc1.4d401f,mp4a.40.2",RESOLUTION=1080x720,FRAME-RATE=25.000
7.	1720/1.m3u8
8.	#EXT-X-STREAM-INF:BANDWIDTH=1185653,AVERAGE-BANDWIDTH=1178677,CODECS="avc1.4d401f,mp4a.40.2",RESOLUTION=1024x580,FRAME-RATE=25.000
9.	1480/1.m3u8

4. Request for submanifest using the same stream key and stream policy:

curl -v GET "https://d326kci5pvyw99.cloudfront.net/transcoded-out/high/11080/1.m3u8" --header 'stream_key: txn6IZLhfnoGBcNKUc56Mu6zAwjL8nrUve5yuKgWg44=' --header 'stream_policy: eyJ1cmwiOiIvdHJhbnNjb2RlZC1vdXQvaGlnaC8qIiwiZXhwaXJlcyI6MTYyODQ0NTczMH0='

1.	#EXTM3U
2.	#EXT-X-VERSION:3
3.	#EXT-X-TARGETDURATION:11
4.	#EXT-X-MEDIA-SEQUENCE:1
5.	#EXT-X-PLAYLIST-TYPE:VOD
6.	#EXTINF:11,
7.	1_00001.ts
8.	#EXTINF:11,
9.	1_00002.ts
10.	#EXTINF:8,
11.	1_00003.ts
12.	#EXT-X-ENDLIST

5. Request for segments using the same stream key and stream policy:

curl -v GET “https://d326kci5pvyw99.cloudfront.net/transcoded-out/high/11080/1_00001.ts” –header ‘stream_key: txn6IZLhfnoGBcNKUc56Mu6zAwjL8nrUve5yuKgWg44=’ –header ‘stream_policy: eyJ1cmwiOiIvdHJhbnNjb2RlZC1vdXQvaGlnaC8qIiwiZXhwaXJlcyI6MTYyODQ0NTczMH0=’

Conclusion

In this post, we showed you how to build a custom security workflow for your video delivery using CloudFront Functions and AWS Lambda. We also provided detailed configuration steps and sample code snippets to help you quickly navigate the security workflow build, detailing the flexibility to customize your media workflow for increased security needs on your video delivery.

Learn more

AWS provides a growing number of services designed to assist in the building of media-related workflows. If you would like to explore additional applications for video streaming, processing, and delivery using AWS Services, visit AWS Media Services. In addition, you can learn more about global video content delivery on Amazon CloudFront. To add custom logic at the content delivery edge, visit CloudFront Functions.

Maheshwaran G

Maheshwaran G

Maheshwaran G is a Specialist Solution Architect working with Media and Entertainment supporting Media companies in India to accelerate growth in an innovative fashion leveraging the power of cloud technologies. He is passionate about innovation and currently holds 8 USPTO and 7 IPO granted patents in diversified domains.