Navigation

Blog|Tutorials

Secure your connection string with AWS KMS

By Brian Morrison II |

Overview

Most developers are fully aware that it is bad practice to store sensitive information in code. If your codebase is accessed by an unauthorized user, that individual now has the means to use whatever that info was trying to protect. For instance, if you were to store your PlanetScale connection string in the codebase, anyone with access to the code can now directly access your database and wreak all sorts of havoc.

One simple way to avoid this is to use environment variables so that sensitive information can be stored in the system that runs the code, as opposed to the code itself. If you are building backend services in AWS, there is actually a way to secure those environment variables even further by encrypting them using a managed key in the AWS KMS service. This article explains what KMS is and how you can use it within a Lambda function.

To follow along with this tutorial, you’ll need the following:

Warning

Please note that we will be creating resources in AWS which cost real money. Resources may be covered under the free tier for AWS users, so check your AWS plan.

As this is a security-focused topic, it's also recommended that you understand the AWS Shared Responsibility Model.

What is KMS

AWS Key Management Service (KMS) is a service provided by AWS that allows you to manage cryptographic keys from a single location. It lets you easily generate symmetric or asymmetric keys, or upload keys you’ve generated yourself, that can be used to encrypt and decrypt data.

When considering the AWS security model, it allows you to dictate not only what information is secured using those keys, but also what services in AWS can access the keys to decrypt information that was previously encrypted using AWS Identity and Access Management (IAM) policies. It’s also designed in such a way that AWS employees have no access to those keys, so not even the individuals working within AWS can decrypt your sensitive information.

Tutorial overview

It is recommended to encrypt your environment variables (such as your PlanetScale connection strings) with a system like KMS to ensure maximum security. Let’s take a look at how to do this using a Lambda function. Here is an overview of what we’ll look at in the rest of this article:

  • You’ll start by creating a Lambda function that reads some data from a PlanetScale database.
  • Then you’ll create a managed key in KMS.
  • Next, the environment variable that stores the connection string will be encrypted, which will cause the function to error.
  • Finally, you’ll update the code to decrypt the connection string, and test reading data from the database again.

The database used for this project will contain a single table called recipes that has the following structure:

+-------------------------+--------------+------+-----+---------+----------------+
| Field                   | Type         | Null | Key | Default | Extra          |
+-------------------------+--------------+------+-----+---------+----------------+
| id                      | int          | NO   | PRI | NULL    | auto_increment |
| name                    | varchar(100) | YES  |     | NULL    |                |
| est_time_to_make_in_min | int          | YES  |     | NULL    |                |
| description             | varchar(100) | YES  |     | NULL    |                |
+-------------------------+--------------+------+-----+---------+----------------+

If you wish to follow the same structure, the following SQL commands can be used to create the table in your database and seed some data. These can be run using the PlanetScale CLI, or in the Dashboard using the Console tab.

CREATE TABLE recipes (
  id INT PRIMARY KEY AUTO_INCREMENT,
  name VARCHAR(100),
  est_time_to_make_in_min INT,
  description VARCHAR(100)
);

INSERT INTO recipes (name, est_time_to_make_in_min, description) VALUES
	('Goatsnake Pizza', 60, 'Lots of deliciousness'),
	('Clutch Pizza', 45, 'This is a pretty awesome pizza too'),
	('Pepperoni Pizza', 30, 'Spicy stuff!');

Create the Lambda function

Start by creating a new folder on your computer to hold the Go project. Open a terminal in that directory and run the following command to initialize the project, replacing $PROJECT_NAME with an arbitrary name for the project:

go mod init $PROJECT_NAME

Create a file named main.go and add the following code. The code is commented so you’ll understand what's going on:

package main

import (
	"database/sql"
	"encoding/json"
	"log"
	"os"
	"github.com/aws/aws-lambda-go/lambda"
	_ "github.com/go-sql-driver/mysql"
)

// The Recipe model will hold the data for a record pulled from the database.
type Recipe struct {
	Id            int
	Name          string
	EstTimeToMake int
	Description   string
}

// Sets up the connection to the PlanetScale database.
func GetDatabase() (*sql.DB, error) {
	db, err := sql.Open("mysql", os.Getenv("DSN"))
	return db, err
}

// The function that will be called when the Lambda function is invoked.
func handler() {
	// Get a database connection
	db, err := GetDatabase()
	if err != nil {
		panic(err)
	}

	// Run the query to fetch all recipes
	query := "SELECT * FROM recipes;"
	res, err := db.Query(query)
	if err != nil {
		panic(err)
	}

	// Loop through the results, placing them in an array of the Recipe struct
	var recipes []Recipe
	for res.Next() {
		var r Recipe
		res.Scan(&r.Id, &r.Name, &r.EstTimeToMake, &r.Description)
		recipes = append(recipes, r)
	}

	// Convert the results to a JSON string and log out that string.
	jbytes, err := json.Marshal(recipes)
	if err != nil {
		panic(err)
	}
	log.Printf("Returned recipes: %v", string(jbytes))
}

// The main entry point for the Lambda service.
func main() {
	lambda.Start(handler)
}

Now head back to the terminal and run the following command to install any missing dependencies:

go mod tidy

Next, you’ll need to build the function and zip up the binary so it can be uploaded to the Lambda service. You’ll need to set the environment variables for GOARCH and GOOS so the compiler creates the appropriate binary for a Lambda environment. Run one of the following commands (depending on your operating system) to create a binary in a dist folder:

// Mac/Linux
GOARCH=amd64 GOOS=linux go build -o dist/main .

// Windows
$Env:GOARCH="amd64"; $Env:GOOS="linux"; go build -o dist/main .

The last step before uploading the function to AWS is to add the binary that was created into a zip file since this is what the Lambda service expects. On a Mac, you can right-click the main file and compress it to create that file.

How to compress a file on a Mac.

Set up and test the Lambda function in AWS

The next step is to configure a Lambda function in AWS and upload the zipped folder from the previous section. In AWS, use the global search to find “Lambda”, and select it from the list of results.

Lambda in the AWS search.

Click on "Create function" to start the wizard to create a Lambda function.

Create a function in Lambda.

Give the function a name and change the "Runtime" to Go 1.x. Leave the rest of the defaults and click "Create function" at the bottom of the form.

The Create function wizard.

Once the function has been created, you’ll need to upload the zip file created in the previous section. From the "Code" source section, select "Upload from" > ".zip file".

Where to upload a zip file in Lambda.

In the next modal, click the "Upload" button and select the zip file from your computer. Click "Save" once you’ve selected it.

The Upload modal.

Next, you’ll need to change the default handler from hello to main, which is the name of the binary that was built for Lambda. Under Runtime settings, click "Edit".

The Edit button in Runtime settings.

Change the Handler field to “main” and click "Save".

The Edit runtime settings view.

Next, select the "Configuration" tab > "Environment variables" > "Edit".

Where to edit environment variables.

Create an entry named “DSN” and paste in the connection string for your PlanetScale database. You can find this in your PlanetScale dashboard by clicking "Connect", clicking the "Connect with" dropdown, and selecting "Go". Once you have it, paste it in and click "Save".

The Edit environment variables view.

Finally, lets test the function and see if we get data back from the database. Select the "Test" tab, then click the "Test" button.

Where to test the Lambda function.

The view should update and display an alert box called Execution result. If you followed all of the previous steps correctly, the box should be green. Expand it and you should see the records from the database under Log output.

The first set of successful results.

Now lets see how to encrypt our connection string with a KMS key. Before moving on from Lambda, you’ll need to grab the execution role for this Lambda. You can find that in the "Configuration" tab under "Permissions". Take note of it as you’ll need it in the next step.

The location of the execution role.

Create a customer managed key in KMS

Start in the AWS console and use the global search to find “key management service”. Select it from the list of available services.

The Key Management Service entry in the search results.

If you do not see a button to create a key immediately, select "Customer managed keys" from the left navigation first. Click "Create key".

Where to create a customer managed key.

As mentioned earlier, AWS lets you create symmetric and asymmetric keys. Both options can be used to encrypt and decrypt data, but asymmetric keys are useful if you need to download the public key for signing other artifacts outside of AWS. Since we’re only working within AWS, leave "Symmetric" selected and click "Next".

The Configure key view.

In the next view under Alias, give the key a display name for your reference and click "Next".

The Add labels view.

Now you need to configure the key administrators, which can be an IAM user, group, or role. Key administrators are users that are allowed to make changes to the key from the AWS console or APIs. For this tutorial, select your own IAM user account. Scroll down and click "Next".

The Define key administrative permissions view.

The next view will let you select IAM users, groups, or roles that are allowed to access your key in KMS. Type the name of the execution role for your Lambda function from the previous section and select it from the list. Click "Next" once you’ve selected it.

The Define key usage permissions view.

Finally, scroll to the bottom and click "Finish".

The final view of the Create key wizard.

Encrypt the connection string in Lambda

Head back to your Lambda function, select the "Configuration" tab > "Environment variables" > "Edit".

Editing the existing environment variables in Lambda.

Now expand the Encryption configuration section. Check the "Enable helpers for encryption in transit" box and you’ll notice that an Encrypt button is now present next to the DSN environment variable.

Enabling encryption for the value.

When you click "Encrypt", a modal will appear where you can select your KMS key created in the previous section. If you expand Decrypt secrets snippet, you’ll also be shown the code you can use to pull in the encrypted value in and decrypt it for use in your code. We’ll be adding this into the Lambda function. Select your KMS key and click "Encrypt".

The Encryption in transit modal.

The value for the DSN environment variable should have updated to an encrypted value. Click "Save".

The Edit environment variables view after the value has been encrypted.

Now if you try to test the code again, it should fail since the code doesn't know what to do with the encrypted connection string. Notice how the error is specifically around how the MySQL driver can’t figure out how to connect to the PlanetScale database.

The failed test after encrypting the connection string.

To fix this, open main.go again on your computer and update the first half of the file (up through GetDatabase()) to look like the following. The imports will be updated, the init() function will be added, and the GetDatabase() function will be updated to reflect the DSN variable which holds the decrypted connection string.

package main

import (
	"database/sql"
	"encoding/json"
	"log"
	"os"
	"encoding/base64"
	"github.com/aws/aws-lambda-go/lambda"
	"github.com/aws/aws-sdk-go/aws"
	"github.com/aws/aws-sdk-go/aws/session"
	"github.com/aws/aws-sdk-go/service/kms"
	_ "github.com/go-sql-driver/mysql"
)

// Set up variables to be used with the encrypted connection string.
var functionName string = os.Getenv("AWS_LAMBDA_FUNCTION_NAME")
var encrypted string = os.Getenv("DSN")
var DSN string

// The init function will run first, decrypting DNS into the above variable.
func init() {
	kmsClient := kms.New(session.New())
	decodedBytes, err := base64.StdEncoding.DecodeString(encrypted)
	if err != nil {
		panic(err)
	}

	input := &kms.DecryptInput{
		CiphertextBlob: decodedBytes,
		EncryptionContext: aws.StringMap(map[string]string{
			"LambdaFunctionName": functionName,
		}),
	}

	response, err := kmsClient.Decrypt(input)
	if err != nil {
		panic(err)
	}
	DSN = string(response.Plaintext[:])
}

// The Recipe model will hold the data for a record pulled from the database.
type Recipe struct {
	Id            int
	Name          string
	EstTimeToMake int
	Description   string
}

// Sets up the connection to the PlanetScale database.
func GetDatabase() (*sql.DB, error) {
	db, err := sql.Open("mysql", DSN) // ← Update the second parameter here
	return db, err
}

// remainder of the code...

Now follow the process from the previous section to build the project, zip it up, and upload it into AWS. Once you do so, test the function again in AWS and it should return data successfully.

The successful test results after updating the code.

Conclusion

If you’ve followed along, you should have a good understanding on how KMS can be used to encrypt sensitive info within an application build on AWS. This is a much more secure way to store connection strings so that even if your AWS account is compromised, unauthorized users would not be able to access your PlanetScale database. While the examples here used Go, the same principles apply to any application, regardless of the language.