Most web applications these days require some form of authentication to identify a user and allow them to reach restricted areas. The most common methods to verify a user's identity are access tokens and the ubiquitous username/password combination. Unless your application mostly contains read-only content or allows users to manipulate your database freely, you'll have some way to let users log in.

When automating tests for these web applications, you'll most likely need to include sensitive information inside your code for authentication purposes. For example, you need to specify an account's username and password if you're testing part of the app requiring authentication. Including data such as API keys or passwords in your codebase is not a good practice, since it allows prying eyes to access these secrets.

Even if your organization restricts access to the code, there's always the risk of code leaks - whether accidental or intentional. An employee could have their work laptop stolen. Someone unwittingly marks your code repository as public. A recently-fired disgruntled team member releases your application's source code online. Many situations can place delicate information into the wrong hands.

For end-to-end tests that require logging in to restricted accounts, it's typically not an issue. Most organizations build separate instances of their servers for testing purposes. These servers often contain fake data, so there's little to no risk of someone outside of the company peering through those accounts since there's no useful information outside of the tests.

However, some scenarios will require automated tests to access live production servers. For instance, I recently worked on a test automation project which required access to third-party services, which didn't provide a test environment. The cost of replicating those services for testing purposes far outweighed the benefits. It was easier to use the live service for a few controlled tests.

The team wanted to restrict usage of these services at a minimum and understandably didn't want to have its credentials accessible to outsiders or the rest of the organization. How could we handle this scenario, allowing our tests to access these services without exposing sensitive data?

Thankfully for the developers and testers on this project, the DevOps team for the organization already had a solution in place - a tool called Vault.

What is Vault?

Vault by HashiCorp is an open-source tool for managing secrets - sensitive data that needs controlled and secured access. You can use Vault to store any information that needs constraints on who can obtain it, like passwords, API keys, personal identification numbers, and more.

Vault uses different engines to store information securely. The most common method Vault uses to store secrets is through its Key-Value engine. A Key-Value store saves data in pairs. The key serves as an identifier of the record through which you can access the saved value. Key-Value stores are more straightforward to use than a relational database, helping you set up flexible schemas in a semi-structured way.

The primary benefit of using a tool like Vault is in its security. Vault contains a variety of features to keep your data secure. It encrypts secrets before writing them to disk. You can generate on-demand dynamic secrets for popular services like AWS. Users can tightly control access to secrets through short lease times and immediate revocation, even shutting down Vault entirely (delightfully known as "sealing" Vault).

Vault is also very flexible. Organizations can write and read secrets through different engines and cloud providers. Anyone with the proper credentials can access Vault's secrets in multiple ways - an HTTP API, a web interface, or using Vault's command-line tools.

Vault has more uses cases for different kinds of businesses and organizations that this article won't dig into. If your team is looking for a centralized place to manage secrets securely, Vault is an excellent tool to consider.

Using Vault in automation using TestCafe

This article won't go into the details of setting up Vault or how its tools work. I'll assume your organization has an instance of Vault up and running, correctly set up to allow the team access through Vault's token authentication method. The article also assumes that the team has stored the relevant secrets for the examples below. If you want to experiment in your local development environment, the HashiCorp team provides a useful Docker image.

The code examples in this article come from a previous Dev Tester article titled How to Get Started with TestCafe. We'll use the setup and one of the tests from the article's code repository to demonstrate how to integrate Vault with a TestCafe test suite. If you're new to TestCafe or want to check out the structure of the test suite we'll use below, read the previous article before diving into the examples here.

The test we'll use to serve as our example is for logging in to the Airport Gap staging website. The test is straightforward. It loads the Airport Gap login page, enters the email address and password for a valid account, and verifies the content from the account page after submitting the form to confirm a successful login.

import loginPageModel from "./page_models/login_page_model";

fixture("Airport Gap Login").page(
  "https://airportgap-staging.dev-tester.com/login"
);

test("User can log in to their account", async t => {
  await t
    .typeText(loginPageModel.emailInput, "[email protected]")
    .typeText(loginPageModel.passwordInput, "airportgap123")
    .click(loginPageModel.submitButton);

  await t.expect(loginPageModel.accountHeader.exists).ok();
});

As you can see in the test, the account's email address and password are in plain text. Anyone with access to this code can see this information. For this example, it doesn't matter because it's a fake account on a staging site with no critical data to protect. But you should hide any sensitive information from easily-accessible places like your codebase. That's where a tool like Vault comes in to protect your secrets. Let's use Vault to avoid exposing the account information in the test.

Assuming you have access to a Vault instance, you can use the node-vault client library to fetch the secrets you need. Since TestCafe is a Node.js tool, node-vault is simple to install and integrates well with your test suite. You can install node-vault as you do with any Node.js package:

npm install node-vault

With the client library installed, we can use it to allow our test to integrate with Vault. Let's modify the login test to use Vault and remove the email address and password from plain sight:

import loginPageModel from "./page_models/login_page_model";

const vault = require("node-vault")({
  endpoint: process.env.VAULT_ADDR,
  token: process.env.VAULT_TOKEN,
});

fixture("Airport Gap Login")
  .page("https://airportgap-staging.dev-tester.com/login");

test("User can log in to their account", async (t) => {
  const loginInfo = await vault.read("secret/data/airportgap/users/1");
  const { username, password } = loginInfo.data.data;
  
  await t
    .typeText(loginPageModel.emailInput, username)
    .typeText(loginPageModel.passwordInput, password)
    .click(loginPageModel.submitButton);

  await t.expect(loginPageModel.accountHeader.exists).ok();
});

The test contains a bit of new code, so let's go through the two areas that have new changes.

const vault = require("node-vault")({
  endpoint: process.env.VAULT_ADDR,
  token: process.env.VAULT_TOKEN,
});

Before beginning our test, we have to set up the node-vault client. This code initializes a new instance of the client to the vault variable with a few configuration options to connect to Vault. The endpoint option points to the Vault server, which is an HTTP URL (like https://vault-server.myorg.com:8200). The token option is the authentication token used to access the secrets inside your Vault instance.

We're using environment variables to set up these configuration options. TestCafe allows you to access environment variables like a typical Node.js application. It helps to keep the authentication token out of the code. Environment variables also add flexibility for configuring our Vault setup for different environments, like running our tests in a local Vault instance in development and a networked instance on a continuous integration system.

const loginInfo = await vault.read("secret/data/airportgap/users/1");
const { username, password } = loginInfo.data.data;
  
await t
  .typeText(loginPageModel.emailInput, username)
  .typeText(loginPageModel.passwordInput, password)
  .click(loginPageModel.submitButton);

Inside our test, we put the node-vault client to use. First, we read the secret from the Vault server using vault.read. read is an async function, so we'll append the await keyword to wait for the function to return its response before proceeding with the test. The function requires a parameter pointing to the path of the secret you want to access.

The path used in the read function depends on how you're storing the secret in your instance of Vault. In this example, we've saved the secret using version 2 of Vault's KV Secrets Engine. Based on the API documentation for this engine, we can fetch secrets using /secret/data/:path. We stored the secret using the path airportgap/users/1, which describes the string used in this example.

The read function returns an object with different bits of information from Vault, as shown in this sample request from a development server:

{
  request_id: 'e05b2196-efe7-5a4f-fcf3-d1920f205cd6',
  lease_id: '',
  renewable: false,
  lease_duration: 0,
  data: {
    data: {
      password: 'airportgap123',
      username: '[email protected]'
    },
    metadata: {
      created_time: '2020-04-17T06:45:29.215973651Z',
      deletion_time: '',
      destroyed: false,
      version: 1
    }
  },
  wrap_info: null,
  warnings: null,
  auth: null
}

We don't need most of this information since it's mostly relevant for Vault debugging purposes. The only thing we need from this response is the data key - in particular, the second data key. Here is where we have our secrets ready to use. We destructure the object to assign the username and password to variables and use them in our test.

Running this test is the same as the original example containing the username and password inside the code. The main difference is now we don't have any sensitive information directly revealed in the test, which is a win for security.

The code used in this example is available on GitHub.

What if an unauthorized person grabs hold of the Vault token?

You might be thinking that there's still a significant risk of the Vault token falling into the wrong hands, supplying unauthorized access to your secrets. It's a possibility, but using Vault has its benefits in these scenarios.

The limitations of the token used in your test suite should be minimal. These types of tokens should provide access only to what the tests need - nothing more, nothing less. The team responsible for generating tokens should limit the scope of usage for the tokens for different use cases. The root token, which provides users access to everything as well as the ability to update admin credentials, should never get used simply for accessing secrets.

Another benefit of Vault is that in case of emergency, it's simple to shut down access for a specific token or even the entire instance of Vault. Depending on your setup, you can also update any secrets instantly after revoking a token, minimizing the impact of leaked credentials.

Finally, Vault provides excellent traceability through its audit devices. When properly configured, Vault keeps a record of every action taken, so you can easily see if there's any suspicious activity. You'll know who used a token or accessed a secret and when the event took place.

If your team acts swiftly, the potential damage of a security breach remains low using Vault, especially when compared to having your credentials directly exposed.

Isn't this overkill? Why not use environment variables for the username and password instead?

Admittedly, using Vault for a small example like this login test is excessive. Setting up a secure instance of Vault is not a simple task, requiring lots of knowledge and resources to run. I wouldn't recommend spending time to use Vault if you have a small team and don't need the control and security provided by Vault.

We could use environment variables to replace the email address and password in the login test to avoid displaying them in plain text. It's a common technique used for preventing sensitive information from getting in a codebase. We even used environment variables to set up the node-vault client in this example.

However, using environment variables isn't adequate in many situations that often occur in any testing project:

  • If you have different areas where you need to use environment variables, juggling all these variables becomes error-prone. For instance, if your tests require multiple account credentials, you're bound to forget to set them correctly at least once.
  • Unless you work in a team of one or two people, there's no easy way to share environment variables with multiple team members securely. Email is one way to share this information but requires someone to perform this action every time someone needs it. I've seen teams share this information by copying and pasting lists of environment variables in public Slack channels, making it pointless from a security perspective.
  • Making changes to the environment variables in use is painful. It ties into the previous point. If you have to add, edit, or delete an environment variable, you need to share it with the rest of the team along with updating your code.

A centralized tool like Vault avoids these security issues and inconveniences. The team doesn't have to deal with ensuring everyone else is up to date, and if there's a security breach, it's a lot simpler to update the secrets, revoke Vault credentials, or shut down Vault altogether. Again, Vault isn't for everyone, but if you have the means, it solves a lot of pain points.

Summary

Good security practices aren't just for production applications - they extend to your test code too. Avoid putting credentials in clear text in your test code, even if very few people have access to the system. You never know when a security breach discloses your most sensitive data.

In the times that you may need to use account credentials or other secret information in your code, you can use a tool like HashiCorp's Vault. It provides a central place to control sensitive data not only for your applications but also for your tests.

This article demonstrates how to use Vault with end-to-end tests for TestCafe, using the node-vault client library. The example test is an introduction for keeping your test code secure and flexible by having important credentials pulled from a safe and reliable source instead of writing them directly in the codebase or mucking around with environment variables.

Note that Vault brings some overhead to set up and manage, so it's not useful for all teams. However, if you have a large team or multiple applications and test suites, it's a valuable option to explore for your organization's infrastructure.

How do your organization and team manage sensitive data for your tests and applications? Share any tips in the comments below!