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.

How to Generate Signed Amazon S3 URLs in Node.js

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:

  1. bucket - The S3 bucket that holds the file ("object" in AWS-speak) we want to retrieve a signed URL for.
  2. key - The path to the file or "object" in our S3 bucket.
  3. 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.

Written By
Ryan Glover

Ryan Glover

CEO/CTO @ CheatCode