This blog can be accessed by anyone with access to the free Internet. It’s a public website. But many websites offer a members-only area. You have to log in to get access to parts of the website. In this blog post, I demonstrate how CloudFront can be used to protect parts of your website from the public.
[wpcc-element _tag=”source” type=”image/webp” srcset=”/images/2021/04/private@730w.webp 730w, /images/2021/04/private@730w2x.webp 1460w, /images/2021/04/private@610w.webp 610w, /images/2021/04/private@610w2x.webp 1220w, /images/2021/04/private@450w.webp 450w, /images/2021/04/private@450w2x.webp 900w, /images/2021/04/private@330w.webp 330w, /images/2021/04/private@330w2x.webp 660w, /images/2021/04/private@545w.webp 545w, /images/2021/04/private@545w2x.webp 1090w” sizes=”(min-width: 1200px) 730px, (min-width: 992px) 610px, (min-width: 768px) 450px, (min-width: 576px) 330px, 545px” _close=”0″]
To serve content only to logged-in users with CloudFront, we have to wire three pieces together:
A private & public key pair.
A signed cookie protected CloudFront origin that uses the public key to verify signed cookies.
A component that generates and returns signed cookies with the private key. We use Lambda@Edge here because CloudFront can trigger it directly.
[wpcc-element _tag=”source” type=”image/webp” srcset=”/images/2021/04/cloudfront-signed-cookies@730w.webp 730w, /images/2021/04/cloudfront-signed-cookies@730w2x.webp 1460w, /images/2021/04/cloudfront-signed-cookies@610w.webp 610w, /images/2021/04/cloudfront-signed-cookies@610w2x.webp 1220w, /images/2021/04/cloudfront-signed-cookies@450w.webp 450w, /images/2021/04/cloudfront-signed-cookies@450w2x.webp 900w, /images/2021/04/cloudfront-signed-cookies@330w.webp 330w, /images/2021/04/cloudfront-signed-cookies@330w2x.webp 660w, /images/2021/04/cloudfront-signed-cookies@545w.webp 545w, /images/2021/04/cloudfront-signed-cookies@545w2x.webp 1090w” sizes=”(min-width: 1200px) 730px, (min-width: 992px) 610px, (min-width: 768px) 450px, (min-width: 576px) 330px, 545px” _close=”0″]
I wired up the pieces using CloudFormation. Let’s have a look at the template step by step.
First , We define the S3 bucket that we use as origins for our CloudFront distribution. We use a single bucket here and separate the public and private files into folders. You could also use two S3 buckets if you wish, or you could use other origin types such as load balancers or external HTTP endpoints.
--- AWSTemplateFormatVersion: '2010-09-09' Description: 'CloudFront Signed Cookies demo by cloudonaut.io' Resources: Bucket: Type: 'AWS::S3::Bucket' Properties: {} BucketPolicy: Type: 'AWS::S3::BucketPolicy' Properties: Bucket: !Ref Bucket PolicyDocument: Statement: - Action: 's3:GetObject' Effect: Allow Resource: !Sub '${Bucket.Arn}/*' Principal: CanonicalUser: !GetAtt CloudFrontOriginAccessIdentity.S3CanonicalUserId - Action: 's3:ListBucket' Effect: Allow Resource: !GetAtt 'Bucket.Arn' Principal: CanonicalUser: !GetAtt CloudFrontOriginAccessIdentity.S3CanonicalUserId CloudFrontOriginAccessIdentity: Type: 'AWS::CloudFront::CloudFrontOriginAccessIdentity' Properties: CloudFrontOriginAccessIdentityConfig: Comment: !Ref 'AWS::StackName'
Second , we create the public key that CloudFront uses to verify the signature in the cookies. To generate a private/Public key pair, run the following commands:
openssl genrsa -out private_key.pem 2048 openssl rsa -pubout -in private_key.pem -out public_key.pem
CloudFrontPublicKey1: Type: 'AWS::CloudFront::PublicKey' Properties: PublicKeyConfig: CallerReference: 'key1' EncodedKey: | -----BEGIN PUBLIC KEY----- REPLACE THIS WITH THE CONTENTS OF public_key.pem -----END PUBLIC KEY----- Name: !Sub '${AWS::StackName}-1' CloudFrontKeyGroup: Type: 'AWS::CloudFront::KeyGroup' Properties: KeyGroupConfig: Items: - !Ref CloudFrontPublicKey1 Name: !Ref 'AWS::StackName'
Managing key rotation in CloudFormation is possible but cumbersome. To add a new public key, duplicate the CloudFrontPublicKey1 resource and reference the new resource in the key group. Please don’t delete the old public key while it is in use. The expiry of your cookies (1 day in this example) defines the minimum wait duration.
Third , it’s time to implement the Lambda@Edge function that generates the signed cookies.
You might want to adjust the following:
MAX_AGE_IN_SECONDS: Expiry of the signed cookie (both the cookie and the content expires)
Private key (from private_key.pem)
The Lambda function checks if the Authorization header contains the value Bearer secret . You likely want to replace this with something different to authenticate the user, e.g., by verifying a JWT.
LambdaEdgeFunctionRole: Type: 'AWS::IAM::Role' Properties: AssumeRolePolicyDocument: Version: '2012-10-17' Statement: - Effect: Allow Principal: Service: - 'lambda.amazonaws.com' - 'edgelambda.amazonaws.com' Action: 'sts:AssumeRole' LambdaEdgeFunctionPolicy: Type: 'AWS::IAM::Policy' Properties: PolicyDocument: Statement: - Effect: Allow Action: - 'logs:CreateLogStream' - 'logs:PutLogEvents' Resource: !GetAtt 'LambdaEdgeFunctionLogGroup.Arn' PolicyName: lambda Roles: - !Ref LambdaEdgeFunctionRole LambdaEdgeFunctionEdgePolicy: Type: 'AWS::IAM::Policy' Properties: PolicyDocument: Statement: - Effect: Allow Action: 'logs:CreateLogGroup' Resource: !Sub 'arn:${AWS::Partition}:logs:*:${AWS::AccountId}:log-group:/aws/lambda/us-east-1.${LambdaEdgeFunction}:log-stream:' - Effect: Allow Action: - 'logs:CreateLogStream' - 'logs:PutLogEvents' Resource: !Sub 'arn:${AWS::Partition}:logs:*:${AWS::AccountId}:log-group:/aws/lambda/us-east-1.${LambdaEdgeFunction}:log-stream:*' PolicyName: 'lambda-edge' Roles: - !Ref LambdaEdgeFunctionRole LambdaEdgeFunction: Type: 'AWS::Lambda::Function' Properties: Code: ZipFile: !Sub | const MAX_AGE_IN_SECONDS = 86400 ; // 1 day const AWS = require('aws-sdk'); const signer = new AWS.CloudFront.Signer( '${CloudFrontPublicKey1}' , `-----BEGIN RSA PRIVATE KEY----- REPLACE THIS WITH THE CONTENTS OF private_key.pem -----END RSA PRIVATE KEY-----` ); exports.handler = async function(event) { if ( 'authorization' in event.Records[0].cf.request.headers && event.Records[0].cf.request.headers.authorization.length === 1 && event.Records[0].cf.request.headers.authorization[0].value === 'Bearer secret' ) { const cookies = signer.getSignedCookie({ policy: JSON.stringify({ Statement: [{ Resource: `https://${!event.Records[0].cf.config.distributionDomainName}/private/*`, Condition: { DateLessThan: { 'AWS:EpochTime': Math.round(Date.now() / 1000 ) + MAX_AGE_IN_SECONDS } } }] }) }); const setCookieHeader = Object.keys(cookies).map(key => ({ key: 'Set-Cookie' , value: `${!key}=${!cookies[key]}; Path=/; Max-Age=${!MAX_AGE_IN_SECONDS}; Secure` })); return { headers: { 'location': [{ key: 'Location' , value: '/private/index.html' }], 'set-cookie': setCookieHeader, 'cache-control': [{ key: 'Cache-Control' , value: 'no-cache' }] }, status: '307' , statusDescription: 'Temporary Redirect' }; } else { return { body: 'missing or invalid authorization header' , bodyEncoding: 'text' , status: '403' , statusDescription: 'Forbidden' }; } }; Handler: 'index.handler' MemorySize: 128 Role: !GetAtt 'LambdaEdgeFunctionRole.Arn' Runtime: 'nodejs12.x' Timeout: 5 LambdaEdgeFunctionVersionV1: Type: 'AWS::Lambda::Version' Properties: FunctionName: !Ref LambdaEdgeFunction LambdaEdgeFunctionLogGroup: Type: 'AWS::Logs::LogGroup' Properties: LogGroupName: !Sub '/aws/lambda/${LambdaEdgeFunction}' RetentionInDays: 14
We use a custom policy here and not a canned policy . Canned policies only grant access to specific files, while custom policies can use the wildcard * character to specify a larger group of files at once.
Last but not least , we define the CloudFront distribution:
CloudFrontCachePolicy: Type: 'AWS::CloudFront::CachePolicy' Properties: CachePolicyConfig: DefaultTTL: 3600 MaxTTL: 86400 MinTTL: 0 Name: !Ref 'AWS::StackName' ParametersInCacheKeyAndForwardedToOrigin: CookiesConfig: CookieBehavior: none EnableAcceptEncodingBrotli: true EnableAcceptEncodingGzip: true HeadersConfig: HeaderBehavior: none QueryStringsConfig: QueryStringBehavior: none CloudFrontDistribution: Type: 'AWS::CloudFront::Distribution' Properties: DistributionConfig: CacheBehaviors: - CachePolicyId: !Ref CloudFrontCachePolicy Compress: true PathPattern: 'private/*' TargetOriginId: private TrustedKeyGroups: - !Ref CloudFrontKeyGroup ViewerProtocolPolicy: 'redirect-to-https' - AllowedMethods: - GET - HEAD - OPTIONS - PUT - PATCH - POST - DELETE CachedMethods: - GET - HEAD - OPTIONS CachePolicyId: !Ref CloudFrontCachePolicy Compress: true LambdaFunctionAssociations: - EventType: 'viewer-request' LambdaFunctionARN: !Ref LambdaEdgeFunctionVersionV1 PathPattern: 'cookie/*' TargetOriginId: public ViewerProtocolPolicy: 'redirect-to-https' DefaultCacheBehavior: CachePolicyId: !Ref CloudFrontCachePolicy Compress: true TargetOriginId: public ViewerProtocolPolicy: 'redirect-to-https' DefaultRootObject: 'index.html' Enabled: true HttpVersion: http2and3 IPV6Enabled: true Origins: - DomainName: !GetAtt 'Bucket.RegionalDomainName' Id: public OriginPath: '/public' S3OriginConfig: OriginAccessIdentity: !Sub 'origin-access-identity/cloudfront/${CloudFrontOriginAccessIdentity}' - DomainName: !GetAtt 'Bucket.RegionalDomainName' Id: private S3OriginConfig: OriginAccessIdentity: !Sub 'origin-access-identity/cloudfront/${CloudFrontOriginAccessIdentity}' Outputs: BucketName: Value: !Ref Bucket CloudFrontDistributionDomainName: Value: !GetAtt 'CloudFrontDistribution.DomainName'
To test the demo, create a CloudFormation stack in us-east-1 based on the template. Once the stack is created, upload two files to the created S3 bucket:
aws s3 cp index.html s3://BUCKET_NAME/public/index.html aws s3 cp index.html s3://BUCKET_NAME/private/index.html
Now you can send requests against CloudFront. To get the public file:
curl https://DOMAIN_NAME/index.html
To set up the cookies:
curl -c cookie.txt -I -X POST -H 'Authorization: Bearer secret' https://DOMAIN_NAME/cookie/
To get a private file with the cookies from the previous request:
curl -b cookie.txt https://DOMAIN_NAME/index.html
That’s all you need to run a public and private website behind CloudFront. I hope it helps!