How to Manage Secret Environment Vars in Xcode and CI
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
toenv.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.
- Prepare dir for the stuff. For eg:
YourFancyProject/Configuration/EnvironmentVars
- 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
- Add
env-example.xcconfig
to git source control. - 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.
- 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.
- Install Sourcery how it's described here
- 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.
- 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
- Create a dir for the script
/YourFancyProject/Scripts
- 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.
- Create script: New file -> Other -> Shell Script ->
write_env_vars_to_env_config.sh
- 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
- Build the project first. It would init
env.xcconfig
with keys and values fromenv-example.xcconfig
- Make sure that
env.xcconfig
is added to git ignore. - Write real secrets to
env.xcconfig
- Make sure that
EnvironmentVars.generated.swift
is added to git ignore - Build the project
- Sanity check: your secrets should be written to
EnvironmentVars.generated.swift
- Import
EnvironmentVars.generated.swift
to the project via File -> Add files - Use your secrets in the project from
EnvironmentVars.generated.swift
file'sEnvironmentVars
struct
On CI
- Check that all secrets are added as the project's environment variables:
How to add new secret env var:
- 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:
- Swift-String-Obfuscator
- Obfuscator with a tutorial right here
- Swift Confidential
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.
Github Link
Code for the scripts above can be found here:
Comments