tutorial // Jul 02, 2021
How to Generate Signed Amazon S3 URLs in Node.js
Access private content in an Amazon S3 bucket using short-term, signed URLs.
Getting started
To sped up our work, we're going to use the CheatCode Node.js Boilerplate as a starting point for our work. To begin, let's clone a copy of that project:
Terminal
git clone https://github.com/cheatcode/nodejs-server-boilerplate.git
Next, we need to install the boilerplate's dependencies:
Terminal
cd nodejs-server-boilerplate && npm install
After this, we need to install the aws-sdk
package from NPM which will give us access to the Amazon S3 API for Node.js:
Terminal
npm i aws-sdk
Finally, start up the development server:
Terminal
npm run dev
With that running, we're ready to begin.
Writing a function for generating signed URLs
Fortunately, the aws-sdk
library gives us a simple function as part of the S3
constructor for generating signed URLs. What we're going to do is write a function that wraps around this and initializes our connection to Amazon S3.
/lib/getSignedS3URL.js
import AWS from "aws-sdk";
import settings from "./settings";
AWS.config = new AWS.Config({
accessKeyId: settings?.aws?.akid,
secretAccessKey: settings?.aws?.sak,
region: "us-east-1",
signatureVersion: "v4",
});
const s3 = new AWS.S3();
After we've imported aws-sdk
up top as AWS
, we set the global AWS.config
value equal to a new instance of the AWS.Config
class (notice the subtle difference between the lowercase cd
on the global we're setting and the capital C
on the constructor function).
To that class, we pass an object with a few different settings. First, we want to pay attention to the accessKeyId
and secretAccessKey
properties. These are set to the keys that we obtain from AWS that associate our calls to S3 with our AWS account.
While obtaining these keys is out of the scope of this tutorial, if you don't already have them, read this official guide on how to create them via AWS IAM (Identity Access Management).
Once you have your keys you can continue with the tutorial.
In the code above, we're not pasting our keys directly into our code. Instead, we're using the settings
feature that's built-in to the boilerplate we're using. It's set up to load the settings for our app on a per-environment basis (i.e., load different keys for our development
environment versus our production
environment).
The file we import here (located at /lib/settings.js
) is responsible for deciding which settings file needs to be loaded when our app starts up (the process kicked off by the npm run dev
command that we ran earlier). By default, the boilerplate includes a settings-development.json
file at the root of the project which is intended to contain our development environment keys (keeping your keys separated by environment prevents unnecessary errors and security issues).
Opening that file up, we want to add the AWS keys you obtained like this:
/settings-development.json
{
[...]
"aws": {
"akid": "",
"sak": ""
},
[...]
}
Here, we add a new property alphabetically to the JSON object at the root of the file called aws
(because we're in a .json
file, we need to use double-quotes). Set to that property is another object containing our keys from AWS. Here, akid
should have its value set to your Access Key ID for your IAM user and sak
should have its value set to your Secret Access Key.
/lib/getSignedS3URL.js
import AWS from "aws-sdk";
import settings from "./settings";
AWS.config = new AWS.Config({
accessKeyId: settings?.aws?.akid,
secretAccessKey: settings?.aws?.sak,
region: "us-east-1",
signatureVersion: "v4",
});
const s3 = new AWS.S3();
Back in our file, with settings
imported, now we can point to our keys with settings.aws.akid
and settings.aws.sak
. The ?
inbetween each property above is a short-hand technique that helps us to avoid writing out settings && settings.aws && settings.aws.akid
(the settings?.aws?.akid
we see above is equivalent to this).
With our keys set, next, we make sure to set the region
where our Amazon S3 bucket lives. Creating an S3 bucket is also out of the scope of this tutorial, so if you haven't already set one up, give this guide from AWS a read and then continue with this tutorial once you've completed it. Make sure to note the region where you create your bucket (if you can't find the dashed version of the region, check this list to find the proper code to pass to region
above that looks-like-this
).
Next, with your region
set, we add signatureVersion
, setting it to v4
(this is the latest version of the AWS signature protocol).
Finally, to round out the snippet above, once we've passed all of our settings to AWS.Config
, we create a variable const s3
and set it equal to a new instance of the AWS.S3()
class.
/lib/generateSignedS3URL.js
import AWS from "aws-sdk";
import settings from "./settings";
AWS.config = new AWS.Config({ ... });
const s3 = new AWS.S3();
export default ({ bucket, key, expires }) => {
const signedUrl = s3.getSignedUrl("getObject", {
Key: key,
Bucket: bucket,
Expires: expires || 900, // S3 default is 900 seconds (15 minutes)
});
return signedUrl;
};
Like we hinted at earlier, the aws-sdk
library makes generating a signed URL fairly simple. Here, we've added a function that we're setting as a default export
. We expect that function to take in a single argument as a JavaScript object with three properties on it:
bucket
- The S3 bucket that holds the file ("object" in AWS-speak) we want to retrieve a signed URL for.key
- The path to the file or "object" in our S3 bucket.expires
- How long in seconds we want the URL to be accessible (after this duration, subsequent attempts to use the URL will fail).
Inside of the function, we create a new variable const signedUrl
which we expect to contain our signedUrl
, here, what we expect to get back from calling s3.getSignedUrl()
. Something that's unique about the .getSignedUrl()
method here is that it's synchronous. This means that when we call the function JavaScript will wait for it to return a value to us before evaluating the rest of our code.
To that function, we pass two arguments: the S3 operation we want to perform (either getObject
or putObject
) and an options object describing what file we want to retrieve a signed URL for.
The operation here should be explained. Here, getObject
is saying that "we want to get a signed URL for an existing object in our S3 bucket." If we were to change that to putObject
, we could simultaneously create a new object and get back a signed URL for it. This is handy if you always need to get back a signed URL (as opposed to getting one after a file has already been uploaded).
For the options object, here, we just copy over the properties from the argument passed to our wrapper function. You'll notice that the properties on the object passed to .getSignedUrl()
are capitalized, whereas the ones passed to our wrapper function are lowercase. In the aws-sdk
, capital letters are used for options passed to functions in the library. Here, we use lowercase for our wrapper function to keep things simpler.
To be safe, for the Expires
option, if we haven't passed a custom expires
value into our wrapper function, we fall back to 900
seconds, or, 15 minutes (this means the URL we get back from Amazon will only be accessible for 15 minutes before it's a dud).
Finally, to wrap up our function, we return signedUrl
. Next, to test this out, we're going to set up a simple Express.js route where we can call to the function.
Wiring up an Express route to test URL generation
As part of the CheatCode Node.js Boilerplate we're using for this tutorial, we're provided with an Express.js server pre-configured. That server is created inside of /index.js
at the root of the project. In there, we create the Express app
and then—to stay organized—pass that app
instance into a series of functions where we define our actual routes (or extend the Express HTTP server).
/api/index.js
import getSignedS3URL from "../lib/getSignedS3URL";
import graphql from "./graphql/server";
export default (app) => {
graphql(app);
app.use("/s3/signed-url", (req, res) => {
const signedUrl = getSignedS3URL({
bucket: "cheatcode-tutorials",
key: "panda.jpeg",
expires: 5, // NOTE: Make this URL expire in five seconds.
});
res.send(`
<html>
<head>
<title>AWS Signed URL Test</title>
</head>
<body>
<p>URL on Amazon: ${signedUrl}</p>
<img src="${signedUrl}" alt="AWS Signed URL Test" />
<script>
setTimeout(() => {
location = "${signedUrl}";
}, 6 * 1000);
</script>
</body>
</html>
`);
});
};
Here, inside of the api()
function that's called from the /index.js
file we just discussed, we take in the Express app
instance as an argument. By default, the boilerplate sets up a GraphQL server for us and here, we seperate the creation of that server off into its own function graphql()
, passing in the app
instance so it can be referenced internally.
Next, the part we care about for this tutorial, we create a test route at /s3/signed-url
in our app (with our server running, this will be available at http://localhost:5001/s3/signed-url
). In the callback for that route, we can see a call being made to our getSignedS3URL()
function (to be clear, our wrapper function). To it, we pass the single options object we've anticipated with bucket
, key
, and expires
.
Here, as a demo, we're passing the cheatcode-tutorials
bucket (used for testing in our tutorials), a file that already exists in our bucket panda.jpeg
as the key
, and expires
set to 5
(meaning, expire the URL we get back and store in const signedUrl
here after five seconds).
We set this fairly low to showcase what happens when a URL is accessed past its expiration time (you will most likely want to set this much higher depending on your use case). To show off how these URLs work, we call to res.send()
to respond to any request to this route with some dummy HTML, displaying the full signedUrl
that we get back from Amazon and—because we know it's a .jpeg
file—rendering that URL in an <img />
tag.
Beneath that, we've added a short script with a setTimeout()
method that redirects the browser to our signedUrl after six seconds. Assuming our expires
value of 5 seconds is respected, when we visit this URL, we expect it to be inaccessible:
In our demo, we can see that when we load the page we get our URL back (along with our panda picture). After six seconds, we redirect to the exact same URL (no changes to it) and discover that AWS throws an error telling us our "request has expired." This confirms that our signed URL behaved as expected and expired five seconds after its creation.
Wrapping up
In this tutorial, we learned how to generate a signed, temporary URL for an S3 object using the aws-sdk
package. We learned how to write a wrapper function that both establishes a connection to AWS and generates our signed URL.
To demonstrate our function, finally, we wired up an Express.js route, returning some HTML with an image tag rendering our signed URL and then redirecting after a few seconds to verify the signed URL expires properly.