Serving content only to logged-in users with CloudFront Signed Cookies

Michael Wittig – 19 Apr 2021

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.

Serving content only to logged-in users with CloudFront Signed Cookies

To serve content only to logged-in users with CloudFront, we have to wire three pieces together:

  1. A private & public key pair.
  2. A signed cookie protected CloudFront origin that uses the public key to verify signed cookies.
  3. A component that generates and returns signed cookies with the private key. We use Lambda@Edge here because CloudFront can trigger it directly.

CloudFront Signed Cookies architecture

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:
# If you change the ZipFile, rename the logical id LambdaEdgeFunctionVersionVX to trigger a new version creation!
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 # 1 hour
MaxTTL: 86400 # 1 day
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 # Is never invoked!
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!

Michael Wittig

Michael Wittig

I’ve been building on AWS since 2012 together with my brother Andreas. We are sharing our insights into all things AWS on cloudonaut and have written the book AWS in Action. Besides that, we’re currently working on bucketAV, HyperEnv for GitHub Actions, and marbot.

Here are the contact options for feedback and questions.