Last updated 8 min read

How to pass secret env vars in Xcode and CI

The tutorial provides a solution for managing secrets as environment variables in Xcode and CI
How to pass secret env vars in Xcode and CI
Photo by Emile Perron / Unsplash
#iOS App Development

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

No one should put secrets into repository. Everyone knows that but keep doing that If you feel like it's you, take a look at the clean up 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 to set environment variables.
  • Local machine also has environment variables.
  • 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 solution consists of two parts:

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

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)
  • Read from env.xcconfig and export env vars to use them in other build phase scripts (env_vars_export.sh)

CI scripts

  • Write from CI environment variables to env.xcconfig

Results

The result of all magic would be generated EnvironmentVars.generated.swift data struct containing all the secrets. The app's code would rely on it.

Prepare env-example.xcconfig

The idea is to have a pair of .xcconfig files. One for example purposes. Another to keep real secrets. Example 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
/content/images/2024/10/Screenshot-2023-06-10-at-16.44.57.png
Screenshot 2023-06-10 at 16.44.57.png

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
/content/images/2024/10/Screenshot-2023-06-10-at-16.38.36.png
Screenshot 2023-06-10 at 16.38.36.png
  1. Add the following script:
#!/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

  1. Install Sourcery how it's described here
  2. Add Sourcery template for environment vars in the EnvVar dir created above:

New file -> Other -> Empty and change the filename to EnvironmentVars.stencil in the dir: /YourFancyProject/Configuration/EnvironmentVars 3. Add the following template code:

//swiftlint:disable all
struct EnvironmentVars {
    /// Generated using 'EnvironmentVars.stencil' template
    static let yourSecret = "{{ argument.YOUR_SECRET }}"
}
  1. 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 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
  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

# Don't run this during index builds
source "$SRCROOT/YourFancyProject/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 write CI environment vars to env.xcconfig file

  1. Create a dir for the script ``/YourFancyProject/Scripts`
  2. Create script: New file -> Other -> Shell Script -> write_env_vars_to_env_config.sh
  3. 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."
  1. 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 on 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

  1. Add secrets to the project's environment variables:
/content/images/2024/10/Screenshot-2023-06-10-at-18.33.41.png
Screenshot 2023-06-10 at 18.33.41.png

Usage

How to use after setup

For local development purposes, developer would need to

  • Build the project first. It would init env.xcconfig with keys and values from env-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
  • Use your secrets in the project from EnvironmentVars.generated.swift file's EnvironmentVars struct

On CI

  • Check that all secrets are added as the project's environment variables:
/content/images/2024/10/Screenshot-2023-06-10-at-18.33.41.png
Screenshot 2023-06-10 at 18.33.41.png

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
  • Add real secret value to env-example.xcconfig
YOUR_SECRET = YOUR_SECRET_REAL_SECRET_VALUE
NEW_SECRET = NEW_SERCRET_REAL_SECRET_VALUE
  • 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 }}"
}
  • Add new NEW_SECRET env var on CI (CircleCI) example
/content/images/2024/10/Screenshot-2023-06-10-at-18.33.41.png
Screenshot 2023-06-10 at 18.33.41.png

Q&A

Why do we need Sourcery?

Sourcery is not a mandatory. Once we have secrets in *.xcconfig file it can be accessed as an Xcode build configuration throught 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 because it's much more convenient to see and use secret vars as EnvironmentVars typed constant, other than to get them from 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:

Scripts for managing env vars in Xcode and CI · GitHub


References

Apple forum Place to mention my post here