How to Use AWS CloudFront Signed URLs in Spring Boot?

If you’re serving private files from Amazon S3, you’ve probably used S3 presigned URLs. They work but they expose your S3 bucket endpoint directly, bypass CloudFront’s CDN caching, and can’t be easily invalidated globally. CloudFront signed URLs solve all three problems.

In this guide, you’ll replace S3 presigned URLs with CloudFront signed URLs in a Spring Boot application. You’ll set up a CloudFront distribution from scratch, configure a trusted key group, and write the Java code to generate signed URLs all step by step.

CloudFront signed URLs

Prerequisites:

  • AWS account with an existing S3 bucket,
  • Java 17+,
  • Spring Boot 3.x, and
  • Basic familiarity with IAM and S3.

Why Use CloudFront Signed URLs Instead of S3 Presigned URLs?

Before jumping into code, understand what you’re gaining.

FeatureS3 Presigned URLCloudFront Signed URL
CDN caching❌ No✅ Yes
Hides S3 endpoint❌ No✅ Yes
Global invalidation❌ No✅ Yes
Custom domain❌ Hard✅ Easy
IP restriction❌ No✅ Via policy
Cost at scaleHigher S3 egressLower (CDN cache hits)

With CloudFront signed URLs, your S3 bucket stays completely private no public access, no bucket policy exceptions. CloudFront acts as the only gateway, and it validates the signed URL before forwarding the request to S3.

Step 1: Block All Public Access on Your S3 Bucket

Your S3 bucket must be fully private. CloudFront will access it through an Origin Access Control (OAC) not through public URLs.

Go to your S3 bucket → Permissions tab → Block public access → Enable all four options → Save.

Block all public access setting for s3 bucket

If it is not enabled already click Edit button and check Block all public Access

Step 2: Create a CloudFront Key Pair for Signing

CloudFront signed URLs require a RSA key pair. You generate the key pair yourself, upload the public key to CloudFront, and keep the private key in your application.

Generate the RSA Key Pair

Run these commands in your terminal:

# Generate a 2048-bit RSA private key
openssl genrsa -out cloudfromt-private-key.pem

# Extract the public key
openssl rsa -pubout -in private_key.pem -out cloudfront-public-key.pem

Keep cloudfront-private-key.pem secret. Never commit it to version control.

Upload the Public Key to CloudFront

  1. Go to CloudFront → Key management → Public keys
  2. Click Create public key
  3. Name: my-app-signing-key
  4. Key value: Paste the contents of cloudfront-public-key.pem
  5. Click Create — note the Public key ID (e.g., K2JCJMDEHXQW5F)

Create a Key Group

  1. Go to CloudFront → Key management → Key groups
  2. Click Create key group
  3. Name: my-app-key-group
  4. Public keys: Select the key you just created
  5. Click Create key group — note the Key group ID

Step 3: Create a CloudFront Distribution

Open CloudFront and Create Distribution

  1. Go to AWS Console → CloudFront → Create distribution -> You can select Free plan and click Next.

image 28

  1. Give Distribution name and if you want to use your custom domain you can provide that name as well anc click Next.
image 29

  1. Under Origin domain, select your S3 bucket from the dropdown (e.g., my-private-bucket.s3.amazonaws.com)
  2. Under Origin access, select Origin access control settings (recommended)
  3. Click Next -> If you want you can enable Security but let’s leave it for now.

image 30

Configure Distribution Settings

  1. Click on the Behaviors tab of your distribution
  2. Select Behaviors and click Edit

image 31

Now verify following:

  • Viewer protocol policy: Redirect HTTP to HTTPS
  • Allowed HTTP methods: GET, HEAD (for read-only file access)
  • Cache policy: CachingOptimized (or create a custom one)
  • Restrict viewer access: Yes — this enforces signed URLs
  • Trusted authorization type: Trusted key groups (recommended over trusted signers)
  • Select the Key group created in the Step 2 and Save.

CloudFront distribution behavior with key group restriction enabled

CloudFront will take 5–10 minutes to deploy. Note your Distribution domain name (e.g., randomvalue.cloudfront.net).

Update the S3 Bucket Policy

After creating the distribution, CloudFront shows a banner: “Copy policy”. Click it, then:

  1. Go to your S3 bucket → Permissions → Bucket policy
  2. Paste the copied policy and save

The policy grants CloudFront’s OAC permission to s3:GetObject on your bucket. It looks like this:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "AllowCloudFrontServicePrincipal",
      "Effect": "Allow",
      "Principal": {
        "Service": "cloudfront.amazonaws.com"
      },
      "Action": "s3:GetObject",
      "Resource": "arn:aws:s3:::my-private-bucket/*",
      "Condition": {
        "StringEquals": {
          "AWS:SourceArn": "arn:aws:cloudfront::123456789012:distribution/EDFDVBD6EXAMPLE"
        }
      }
    }
  ]
}

Replace my-private-bucket and the distribution ARN with your actual values.

Step 4: Store the Private Key Securely

Never hardcode your private key. Store it in AWS Secrets Manager or as an environment variable.

How to Connect Spring Boot to AWS Secrets Manager Dynamically (Without Git Exposure)

  1. From the services list select Serects Manager
  2. Click Store a new secret button
  3. Select Other type of secret
  4. If you are storing multiple key/values under a single secret select Plaintext and create json object and store. This will be the easiest way to store. I prefer this.
  5. Click Next

image 34

  1. Configure secret and give secret name it will required later to load from our code.

image 35

  1. Click Next and leave everything default and click Store.

Option B: Environment Variable (Dev/Test Only)

export CLOUDFRONT_PRIVATE_KEY="$(cat cloudfront-private-key.pem)"

Step 5: Add Dependencies to Spring Boot

Add the AWS SDK CloudFront and also spring cloud starter dependency for AWS in your pom.xml.

<dependencyManagement>
        <dependencies>
            <dependency>
                <groupId>org.springframework.cloud</groupId>
                <artifactId>spring-cloud-dependencies</artifactId>
                <version>${spring-cloud.version}</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
            <dependency>
                <groupId>io.awspring.cloud</groupId>
                <artifactId>spring-cloud-aws-dependencies</artifactId>
                <version>3.3.1</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
        </dependencies>
    </dependencyManagement>



<dependencies>
        
        <dependency>
            <groupId>com.amazonaws</groupId>
            <artifactId>aws-java-sdk-cloudfront</artifactId>
            <version>1.12.787</version>
        </dependency>

        <dependency>
            <groupId>io.awspring.cloud</groupId>
            <artifactId>spring-cloud-aws-starter-secrets-manager</artifactId>
        </dependency>

    </dependencies>

Step 6: Configure Application Properties

Since we are using spring cloud aws starter it will be easier for us to read secrets. So add your CloudFront settings to application.yml:

spring:
  config:
    # Instructs Spring to pull the specific JSON payload block at startup.
    import: aws-secretsmanager:cloudfront/private-key # Secrets Manager secret name
  cloud:
    aws:
      region:
        # Explicitly designates which cloud data region contains the secret.
        static: us-west-2
      secretsmanager:
        enabled: true

cloudfront:
  distribution-domain: randomstring.cloudfront.net
  key-pair-id: K2JCJMDEHXQW5F          # Your Public Key ID from Step 2
  url-expiry-minutes: 60

Step 7: Write the Spring Boot Service

Service Implementation Class

import com.amazonaws.auth.PEM;
import com.amazonaws.services.cloudfront.CloudFrontUrlSigner;
import com.zurelsoft.s3service.service.url.CloudFrontProperties;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import org.springframework.web.util.UriComponentsBuilder;

import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.security.PrivateKey;
import java.security.spec.InvalidKeySpecException;
import java.util.Date;

/**
 * Signed URL provider backed by CloudFront signed URLs.
 */
@Service
public class CloudFrontSignedUrlService {

	private final String privateKey;
	private final Long expiryMinutes;
	private final String publicKeyId;
	private final String domain;

	public CloudFrontSignedUrlService(
			@Value("${cloudfront-private-key}") String privateKey,
			@Value("${cloudfront.url-expiry-minutes}") Long expiryMinutes,
			@Value("${cloudfront.key-pair-id}") String publicKeyId,
			@Value("${cloudfront.distribution-domain}") String domain) {
		this.privateKey = privateKey;
		this.expiryMinutes=expiryMinutes;
		this.publicKeyId=publicKeyId;
		this.domain=domain;
	}

	/**
	 * You can call this method to get signed download url for the file.
	 */
	public String getSignedDownloadUrl(String s3FileKey) {
		validateConfiguration();

		Date expirationDateTime = new Date(
				System.currentTimeMillis() + (1000L * 60L * expiryMinutes));
		String resourceUrl = buildResourceUrl(s3FileKey);

		try {
			PrivateKey privateKey = loadPrivateKey();
			return CloudFrontUrlSigner.getSignedURLWithCannedPolicy(resourceUrl,
					publicKeyId, privateKey, expirationDateTime);
		}
		catch (InvalidKeySpecException | IOException exception) {
			throw new RuntimeException("Unable to generate CloudFront signed URL.", exception);
		}
	}

	private void validateConfiguration() {
		if (!StringUtils.hasText(domain)) {
			throw new IllegalStateException("CloudFront domain is required.");
		}
		if (!StringUtils.hasText(publicKeyId)) {
			throw new IllegalStateException("CloudFront public key id is required.");
		}
		if (!StringUtils.hasText(privateKey)) {
			throw new IllegalStateException("CloudFront private key is required.");
		}
	}


	private String buildResourceUrl(String key) {
		UriComponentsBuilder builder = UriComponentsBuilder.fromHttpUrl(
				trimTrailingSlash(domain));
		for (String pathSegment : key.split("/")) {
			if (StringUtils.hasText(pathSegment)) {
				builder.pathSegment(pathSegment);
			}
		}
		return builder.build().encode(StandardCharsets.UTF_8).toUriString();
	}

	private PrivateKey loadPrivateKey() throws InvalidKeySpecException, IOException {
		byte[] privateKeyBytes = privateKey.replace("\\n", "\n")
				.getBytes(StandardCharsets.UTF_8);
		try (ByteArrayInputStream inputStream = new ByteArrayInputStream(privateKeyBytes)) {
			return PEM.readPrivateKey(inputStream);
		}
	}

	private String trimTrailingSlash(String value) {
		if (!StringUtils.hasText(value)) {
			return value;
		}
		return value.endsWith("/") ? value.substring(0, value.length() - 1) : value;
	}
}

For local development, the SDK picks up credentials from ~/.aws/credentials or environment variables (AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY). For production on EC2/ECS/Lambda, use IAM roles — no hardcoded credentials needed.

Complete Working Example

Here’s the full flow in action. Given an S3 object at key uploads/documents/Q3-report.pdf:

Request:

GET /api/files/signed-url?key=uploads/documents/Q3-report.pdf

Response:

{
  "url": "https://d1abc2defg3hij.cloudfront.net/uploads/documents/Q3-report.pdf?Expires=1719316800&Signature=ABCD...XYZ&Key-Pair-Id=K2JCJMDEHXQW5F"
}

The user clicks this URL. CloudFront validates the signature, checks the expiry, then fetches from S3 via the OAC — all transparently. The S3 bucket URL is never exposed.

cloudfront signed url sequence

Common Errors & Fixes

Error 1: 403 Forbidden from CloudFront

Cause: The S3 bucket policy doesn’t grant the OAC permission, or the behavior doesn’t have Restrict viewer access enabled.

Fix: Re-copy the bucket policy from CloudFront’s banner and paste it into your S3 bucket policy. Double-check the distribution ARN in the Condition block.

Error 2: InvalidSignature or MissingKey

Cause: The Key-Pair-Id in the signed URL doesn’t match any key in the trusted key group attached to the distribution behavior.

Fix: Verify the keyPairId in your application.yml matches the Public Key ID (not the key group ID) in CloudFront → Key management → Public keys.

Error 3: java.security.spec.InvalidKeySpecException

Cause: The private key is in PKCS#1 format (starts with -----BEGIN RSA PRIVATE KEY-----) but the Java PKCS8EncodedKeySpec expects PKCS#8.

Fix: Convert the key before storing it:

openssl pkcs8 -topk8 -nocrypt -in cloudfront-private-key.pem -out cloudfront-private-key-pkcs8.pem

Store and use the PKCS#8 version.

Error 4: URL Works Once Then Expires Too Fast

Cause: Server clock skew — your EC2 or container clock is drifting, making the expiry appear in the past.

Fix: Sync your system clock with NTP. On Amazon Linux:

sudo yum install -y chrony
sudo systemctl enable chronyd --now

Best Practices

  • Cache the private key in memory. Loading from Secrets Manager on every request adds latency. Load once at startup or use a @PostConstruct cache.
  • Keep expiry times short 15 to 60 minutes is usually sufficient. Shorter windows reduce the window of abuse if a URL leaks.
  • Use custom policies for sensitive files when you need IP restriction or access windows that don’t start immediately.
  • Never block CloudFront’s IP ranges in your S3 bucket policy the OAC uses AWS service principals, not IP addresses.
  • Use CloudFront invalidations sparingly they cost money after the first 1,000 per month. Design your key paths to avoid needing mass invalidations.
  • Log CloudFront access enable CloudFront access logs to an S3 bucket for auditing who accessed what and when.

Conclusion

You’ve replaced S3 presigned URLs with CloudFront signed URLs in Spring Boot. Your S3 bucket is now fully private, your files are served through a CDN with global caching, and your signing key is safely stored in Secrets Manager.

For further reading, see the official AWS CloudFront signed URLs documentation and the AWS SDK for Java v2 CloudFront module.

Sharing Is Caring: