9 min read

How to Manage Secret Environment Vars in Xcode and CI

The tutorial provides a solution for managing secrets as environment variables in Xcode and CI
How to Manage Secret Environment Vars in Xcode and CI
Photo by Emile Perron / Unsplash

Apps always have secrets: API keys, shared secrets, passwords, or some sort of auth tokens.

No one should put secrets into the repository. Everyone knows that but keep doing that. If you feel like it's you, take a look at the cleanup guide.

Actually, it's Xcode's fault.

Unless you are using Xcode Cloud, there is no convenient tool for managing secrets out-of-the-box that would run smoothly both locally and on CI.

There used to be a tool called BuddyBuild that was injecting secrets, but it has gone and now only redirects to Xcode Cloud.

What's the problem

  • CI services always allow setting environment variables.
  • The local machine also has env vars.
  • Xcode has build phase scripts that are run before or after the app is built.

What could go wrong?

Build phase scripts are run s by Xcode as forked processes. They don't have any access to the scope of environment variables that are provided by CI.

It means that we need a workaround to pass the secrets.

Here is how.

Overview

The whole solution consists of two parts:

  • CI-only script
  • Pre-build scripts implemented as build phase scripts for Xcode

CI scripts

  • Write from CI environment variables to env.xcconfig

Pre-build scripts

  • Copy env-example.xcconfig to env.xcconfig if it doesn't exist (env_vars_init.sh)
  • Read from env.xcconfig and perform code generation with Sourcery template (env_vars_codegen.sh)
  • (Optional) Read from env.xcconfig and export env vars to use them in other build phase scripts (env_vars_export.sh)

Results

The result of all magic would be generated EnvironmentVars.generated.swift data struct containing all the secrets from env.xcconfig for local development and secrets from environment variables on CI

//swiftlint:disable all
struct EnvironmentVars {
    /// Generated using 'EnvironmentVars.stencil' template
    static let yourSecret = "SECRET12345"
}

The app's code would then rely on secrets from EnvironmentVars.

Setup

Prepare env-example.xcconfig

The idea is to have a pair of .xcconfig files. One for example purposes. Another is to keep real secrets. The example file would be under source control, while the one with real secrets would be in git ignore.

  1. Prepare dir for the stuff. For eg: YourFancyProject/Configuration/EnvironmentVars
  2. Create env-example.xcconfig in Xcode: New file -> Other -> Configuration Settings File -> env-example.xcconfig


3. Add env vars examples to env-example.xcconfig

YOUR_SECRET = YOUR_SECRET_EXAMPLE

  1. Add env-example.xcconfig to git source control.
  2. Add env.xcconfig to .gitignore

Initialization script for env.xcconfig

The Initialization script would copy env vars from env-example.xcconfig to env.xcconfig if env.xcconfig does not exist.

  1. Add env vars init scipt: Project -> Target -> Build phases -> New Run script Phase

2. Add the following code:

#!/bin/bash

# This script should be run as a phase script before building the project
#
# The script:
# 1. Copies .env-example.xcconfig to .env.xcconfig if .env.xcconfig does not exist

if [ $ACTION = "indexbuild" ]; then exit 0; fi

echo "EnvironmentVars init"

CONFIGURATION_PATH="${SRCROOT}/YourFancyProject/Configuration/EnvironmentVars"

# Path to the env.xcconfig file

xcconfig_file="${CONFIGURATION_PATH}/env.xcconfig"

if [ -f "$xcconfig_file" ]; then
  echo "The file '$xcconfig_file' found."
else
  echo "The file '$xcconfig_file' does not exist."
  example_xcconfig_file="${CONFIGURATION_PATH}/env-example.xcconfig"
  cp "$example_xcconfig_file" "$xcconfig_file"
  echo "Copied from '$example_xcconfig_file' to '$xcconfig_file'"
fi

Setup Sourcery

We would use Sourcery for code generation purposes.

  1. Install Sourcery how it's described here
  2. Add Sourcery template for environment vars in the EnvironmentVars dir created above (/YourFancyProject/Configuration/EnvironmentVars): New file -> Other -> Empty

3. Change the filename to EnvironmentVars.stencil

4. Add the following template code:

//swiftlint:disable all
struct EnvironmentVars {
    /// Generated using 'EnvironmentVars.stencil' template
    static let yourSecret = "{{ argument.YOUR_SECRET }}"
}

5. Add EnvironmentVars.generated.swift to git ignore. It will contain secrets after all magic is done

Code generation script

The script would read the env vars from env.xcconfig file, prepare the list of arguments in a proper format and run the Sourcery code generation with the EnvironmentVars.stencil template.

  1. Add code gen env vars script: Project -> Target -> Build phases -> New Run script Phase:
# This script should be run as a phase script before building the project
#
# The script:
# 1. Reads env vars from .env.xcconfig
# 2. Performs EnvironmentVars code generation using Sourcery and EnvironmentVars.stencil template

if [ $ACTION = "indexbuild" ]; then exit 0; fi

echo "EnvironmentVars CodeGen"

CONFIGURATION_PATH="${SRCROOT}/YourFancyProject/Configuration/EnvironmentVars"
TEMPLATE_PATH="${CONFIGURATION_PATH}/EnvironmentVars.stencil"

# Path to the env.xcconfig file
xcconfig_file="${CONFIGURATION_PATH}/env.xcconfig"

if ! [ -f "$xcconfig_file" ]; then
    echo "The file '$xcconfig_file' does not exist. Run 'env_vars_init.sh' first."
    exit 1;
fi

echo "Performing EnvironmentVars CodeGen."

# Declare an empty string to hold the env variables in comma separated format
env_vars=""

# Read each line from the xcconfig file
while IFS= read -r line; do
  # Ignore comments and empty lines
  if [[ "$line" =~ ^\s*# ]] || [[ -z "$line" ]]; then
    continue
  fi

  # Extract the variable name and value from the line
  var_name="$(echo "$line" | cut -d'=' -f1 | sed 's/ //g')"
  var_value="$(echo "$line" | cut -d'=' -f2- | sed 's/^ *//;s/ *$//')"

  # Append the variable to the env_vars string
  env_vars+="$var_name=$var_value,"
done < "$xcconfig_file"

# Remove the trailing comma
env_vars="${env_vars%,}"

# Run Sourcery Codegen
$PODS_ROOT/Sourcery/bin/sourcery --templates "${TEMPLATE_PATH}" --sources "${CONFIGURATION_PATH}" --output "${CONFIGURATION_PATH}" --args "$env_vars"

This script is legit for Sourcery installation done via CocoaPods.

If Sourcery is installed in another way the Sourcery call in the end should point to the correct binary location.

Env vars export script

If your project has some build phase script that requires your secret env vars. You can use env vars export script to obtain them. It would read the secrets from env.xcconfig and export them as environment vars.

The common use case is to pass auth token to fetch GraphQL schema in a build phase script.

Let's do it

  1. Create a dir for the script /YourFancyProject/Scripts
  2. Create script: New file -> Other -> Shell Script -> env_vars_export.sh

(no need to add it to any of the project's targets)

3. Add the following code to the script file:

#!/bin/bash

# This script could be run as a phase script if env vars needed
#
# The script:
# 1. Reads env vars from .env.xcconfig
# 2. Exports env vars

if [ $ACTION = "indexbuild" ]; then exit 0; fi

echo "EnvironmentVars Export"

CONFIGURATION_PATH="${SRCROOT}/YourFancyProject/Configuration/EnvironmentVars"

# Path to the env.xcconfig file
xcconfig_file="${CONFIGURATION_PATH}/env.xcconfig"

if ! [ -f "$xcconfig_file" ]; then
    echo "The file '$xcconfig_file' does not exist. Run 'env_vars_init.sh' first."
    exit 1;
fi

echo "Performing EnvironmentVars Export..."

# Read each line from the xcconfig file
while IFS= read -r line; do
  # Ignore comments and empty lines
  if [[ "$line" =~ ^\s*# ]] || [[ -z "$line" ]]; then
    continue
  fi

  # Extract the variable name and value from the line
  var_name="$(echo "$line" | cut -d'=' -f1 | sed 's/ //g')"
  var_value="$(echo "$line" | cut -d'=' -f2- | sed 's/^ *//;s/ *$//')"
  export "$var_name"="$var_value"
done < "$xcconfig_file"

echo "EnvironmentVars Exported."

Usage example:

#!/bin/bash

source "$SRCROOT/Scripts/env_vars_export.sh"

echo "${YOUR_SECRET}"

Write Env Vars to env.xcconfig on CI

The final part of the whole thing is to create the flow that would run on CI.

The script would read env var keys and example values from `env-example.xcconfig` file.

For each key, it would check if CI has a corresponding environment variable defined and write it to `env.xcconfig` file. Otherwise, it would write the example value for it.

The rest of the flow is already defined in Xcode build phase scripts and would run afterwards.

  1. Create script: New file -> Other -> Shell Script -> write_env_vars_to_env_config.sh
  2. Add the following code to the script file:
#!/bin/bash


# This script should be run on CI from project dir as a step before building the project
# First, Make sure that all env vars from .env-example.xcconfig are set on CI
#
# The script:
# 1. Reads all env vars defined in env-example.xcconfig
# 2. Writes env var values to env.xcconfig

config_path="./YourFancyProject/Configuration/EnvironmentVars"


# Path to the env.xcconfig file
xcconfig_file="${config_path}/env.xcconfig"

# Path to the env.xcconfig file
example_xcconfig_file="${config_path}/env-example.xcconfig"

# Open the source and destination files for reading and writing
exec 3<$example_xcconfig_file
exec 4>$xcconfig_file

echo "Writing env vars to '$xcconfig_file'..."

# Read the source file line by line and write to the destination file
while read -u 3 line; do
  # Extract the variable name and value from the line
  var_name="$(echo "$line" | cut -d'=' -f1 | sed 's/ //g')"
  default_var_value="$(echo "$line" | cut -d'=' -f2- | sed 's/^ *//;s/ *$//')"
  
  # Extract the variable value from env vars
  var_value="${!var_name}"
  
  if [[ -n "$var_value" ]]; then
    echo "$var_name = $var_value" >&4
    echo "Wrote '$var_name' from env vars."
  else
    echo "$var_name = $default_var_value" >&4
    echo "Wrote default value for '$var_name'."
  fi
done

# Close the files
exec 3<&-
exec 4>&-

echo "Wrote env vars to '$xcconfig_file' successfully."

4. Add write_env_vars_to_env_config.sh script as a command to one of the job steps on CI.

Here is a CircleCI example, but you can do the same thing with any another CI service:

version: 2.1
 
commands:
  write_env_vars:
    steps:
      - run:
         name: Write Env Vars to env.xcconfig
         command: Scripts/write_env_vars_to_env_config.sh

5. Add secrets to the project's environment variables:

Usage

How to use after setup

For local development purposes, the developer would need to

  1. Build the project first. It would init env.xcconfig with keys and values from env-example.xcconfig
  2. Make sure that env.xcconfig is added to git ignore.
  3. Write real secrets to env.xcconfig
  4. Make sure that EnvironmentVars.generated.swift is added to git ignore
  5. Build the project
  6. Sanity check: your secrets should be written to EnvironmentVars.generated.swift
  7. Import EnvironmentVars.generated.swift to the project via File -> Add files
  8. Use your secrets in the project from EnvironmentVars.generated.swift file's EnvironmentVars struct

On CI

  1. Check that all secrets are added as the project's environment variables:

How to add new secret env var:

  1. Add it as an example to env-example.xcconfig:
YOUR_SECRET = YOUR_SECRET_EXAMPLE
NEW_SECRET = NEW_SERCRET_EXAMPLE_VALUE

2. Add real secret value to env.xcconfig:

YOUR_SECRET = YOUR_SECRET_REAL_SECRET_VALUE
NEW_SECRET = NEW_SERCRET_REAL_SECRET_VALUE

3. Update .stencil template

//swiftlint:disable all
struct EnvironmentVars {
    /// Generated using 'EnvironmentVars.stencil' template
    static let yourSecret = "{{ argument.YOUR_SECRET }}"
    static let yourNewSecret = "{{ argument.NEW_SECRET }}"
}

4. Add new NEW_SECRET env var on CI (CircleCI) example

Q&A

Why do we need Sourcery?

Sourcery is not mandatory. Once we have secrets in *.xcconfig file it can be accessed as an Xcode build configuration through a .plist variable.

But.

You are likely already using Xcode build configuration for other purposes.

For eg, to configure the project with environment-dependent constants, like API-base URL, or other settings.

I believe that secrets deserve a special place:

  • We want to keep secrets out of source control.
  • We want to keep other configuration variables under source control.

So we don't better mess them all together.

Another reason to use Sourcery is that it's much more convenient to see and use secret vars as EnvironmentVars typed constant, other than to get them from the Bundle info dictionary in the runtime.

What can be improved?

We should be aware that Strings are still not secure with that setup.

When we build and ship the app with secrets defined as string literals, they can be relatively easily reverse-engineered from the app's IPA package.

If we want to make it more secure we would need to think about applying the obfuscation of EnvironmentVars properties definitions.

Luckily, when we have all the workflow setup with an EnvironmentVars data struct as an output, the obfuscation step, in the end, is rather straightforward.

It can be done with one of the libs:

That's why EnvironmentVars data struct still has some room for improvement. If we want to make it more secure we would need to think about applying the obfuscation of EnvironmentVars properties definitions.

Summary

As the result we get a pretty much reliable solution for managing secrets.

Code for the scripts above can be found here: