Limit permissions with roles for signed URLs

Using single-purpose roles for signing URLs helps achieve the least privilege. But there are some pitfalls along the way.

Author's image
Tamás Sallai
5 mins

Permissions and signed URLs

When you sign a URL with the global credentials (execution role for Lambda/ECS, instance profile for EC2, or IAM users) you get all the available permissions that come with it. If a user can trick your backend to sign a URL for a different bucket, for example, that could lead to security vulnerabilities. Using different sets of permissions for different purposes can help enforce the least privilege.

What are the use cases?

You have 2 buckets with 2 endpoints to sign URLs for them:

  • Endpoint 1 signs for bucket 1
  • Endpoint 2 signs for bucket 2

In this case, you want to make sure that clients cannot trick one of your endpoints to sign a request for the other bucket. If you use the global credentials then both endpoints have access to both buckets and only the checks in the code enforce they only grant access to the correct bucket. If you use separate roles that have access to only one of the buckets your endpoints will be more secure.

Another example is when you want to make sure a URL can only be signed with a given prefix, such as for objects starting with user/. The global credentials have access to the whole bucket, but using a role makes sure that no matter how insecure your signer function is it cannot get objects outside the prefix.

And finally, you can lock down a bucket to a specific role which means it's easier to write and reason about the bucket policy.

Global credentials

When you don't specify any credentials the AWS SDK uses the global ones. This is usually how you get the S3 service:

const s3 = new AWS.S3();

Later, when you sign the URL, the global credentials will be used:

const url = s3.getSignedUrl(...);

Thus it will have access to everything the global credentials can access.

Specifying credentials

To use different credentials for a given service use the {credentials: ...} parameter in the constructor:

const s3 = new AWS.S3({credentials: ...});

When you use this object to sign URLs it will use the provided credentials.

Using roles

Roles are temporary credentials so you need to refresh them every now and then. Fortunately, there is AWS.ChainableTemporaryCredentials which does that for you transparently:

const credentials = new AWS.ChainableTemporaryCredentials({
	params: {RoleArn: roleArn}
});

const s3 = new AWS.S3({credentials});

This uses the global credentials to assume a role identified by roleArn then use that to sign URLs.

Async

Assuming a role is an asynchronous operation, which means the credentials it provides are not available immediately.

AWS SDK operations can usually be called both async or sync. For example, getSignedUrl supports both:

// synchronous
const url = s3.getSignedUrl("getObject", {...});

// async
s3.getSignedUrl("getObject", {...}, (err, url) => {
	// url
})

If you use the sync version you need to make sure the credentials provider has a chance to make the calls it needs to get valid credentials. This can be done with getPromise(). Wait for the resulting promise to resolve and then you can safely use the sync version:

s3.config.credentials.getPromise().then(() => {
	// credentials are in place

	const url = s3.getSignedUrl("getObject", {...});
});

Expiration

Another problem is the expiration. Signed URLs expire in two ways:

First, the signature can expire. When you sign a URL, you specify how long it will be valid:

const url = s3.getSignedUrl("getObject", {
	Expires: ...,
});

The default is 15 minutes if you omit it.

This expiration time is an observable property of the URL as it is included in the query part: &Expires=.... And when it expires it gives back an AccessDenied error:

<Error>
	<Code>AccessDenied</Code>
	<Message>Request has expired</Message>
	...
</Error>

The second way URLs expire is when the signer loses access to the operation. In the case of roles it is when the credentials expire.

const credentials = new AWS.ChainableTemporaryCredentials({
	params: {
		RoleArn: roleArn,
		DurationSeconds: ...,
	}
});

It defaults to 1 hour.

When the credentials expire it gives back a different error:

<Error>
	<Code>ExpiredToken</Code>
	<Message>The provided token has expired.</Message>
	...
</Error>

Since access keys for IAM users do not expire you might not even notice there is a problem during development.

The two ways of expirations both determine how long the URLs are valid. If the signature is valid for 15 minutes but the session has only 5 minutes remaining the effective duration is only 5 minutes. This is usually a problem for URLs signed close to the end of the session which is one of the worst kind of bugs to debug.

Fortunately, there is a parameter that controls the role refresh behavior. It is called expiryWindow and it defines how many seconds before expiration the role should be refreshed. In effect, the credentials will be valid for at least this many seconds.

const credentials = new AWS.ChainableTemporaryCredentials({
	params: {RoleArn: roleArn}
});
credentials.expiryWindow = 15 * 60; // 15 minutes

const s3 = new AWS.S3({credentials});

Setting this property makes sure the credentials will expire no sooner than the signature on the URL.

Conclusion

Using the least privilege is a good security practice, especially for endpoints that clients can influence. Using separate roles for these specific tasks can prevent problems if the endpoint is compromised.

June 18, 2019
In this article