Hiding Secrets From Git in SwiftPM

A step-by-step guide on how to prevent your 3rd party service secrets from committing to Git when using apps modularized with SwiftPM.

Hiding Secrets From Git in SwiftPM
Photo by FLY:D / Unsplash

You may be aware of some traditional methods of hiding secrets like an API key or some 3rd party services’ token you need for your app. But nowadays approaches to modularize your app using SwiftPM become more and more popular.

For example, Point-Free has a great free episode on this topic and Majid Jabrayilov recently wrote a 4-parts series on “Microapps architecture” (parts 1, 2, 3, 4) which I can both recommend to get started.

Also, you might even want to hide secrets in public Open Source libraries, e.g. in the unit tests of some 3rd party service integration where users of the library will provide their own token, but you want your tests to run with your own.

What these situations have in common is that they are based on a custom maintained Package.swift file — not the one Xcode maintains for you if you just add a dependency to an app project. The app or project is split into many small modules with no corresponding .xcodeproj file, Xcode just opens the Package.swift file directly, without the need for a project.

This also means, that for the separate modules, there’s no way to specify any build settings or build scripts within Xcode, all needs to be done right within the Package.swift manifest file.

While more and more such features are being added to SwiftPM (like SE-303, SE-325, SE-332) in future releases, there’s no sign they will support any Xcode-specific features such as .xcconfig files.

How can we hide secrets from committing to Git to ensure we’re not leaking them to our Git provider or anyone else with access to our repo today?

SwiftPM resources & JSONDecoder

I’m sure there’s no single “best” answer to this and others may have smarter ideas than mine. But I like to keep things simple and I also like to use basic features because I know them well & I expect other developers to understand them quickly if needed. Plus I can be sure they’re future-proof.

The approach I want to use is the classical .env file approach which is common in web development. But instead of a custom-formatted .env file, I want to simply have a .json file with my secrets in it, because JSON files are familiar to many iOS developers and we have built-in support for parsing them in Swift thanks to JSONDecoder. Loading files or more generally “resources” is also supported by SwiftPM since Swift 5.3 (SE-271).

Here’s the basic idea of how I want to hide secrets from Git outlined:

  1. Check in a secrets.json.sample file into Git with the keys, but no values
  2. Let developers duplicate it , remove the .sample extension & add values
  3. Ignore the secrets.json file via .gitignore so it’s never checked in
  4. Provide a simple struct conforming to Decodable to read the secrets

The rest of this article is a step-by-step guide on how to apply this approach. I will be using the unit tests of my open source translation tool BartyCrouch which integrates with two 3rd-party translation services as an example.

⚠️ Please note that if you plan to apply this approach to an app target which you will ship to users, you will probably run into the same problem as described in the .xcconfig approach in this NSHipster article. My method only helps hiding the secrets from Git, you will need additional obfuscation if you plan to ship to users.

Adding the secrets.json resource file

First, let’s add the secrets.json file to our project. As there’s going to be a corresponding secrets.json.sample and a Secrets.swift file, I opt for creating a folder Secrets first, then I create an empty file which I name secrets.json and I add a simple JSON dictionary structure with two keys:

The `secrets.json` file with two actual secrets, added to the project.

Second, let’s ensure we can’t accidentally commit that file by appending secrets.json to our .gitignore file. If you don’t have a .gitignore file in your project yet, just create one at the root of your repository, e.g. by running touch .gitignore. If you can’t see the file in your Finder, just turn on showing hidden files via Cmd+Shift+.. The result should look something like this:

The `secrets.json` entry in the `.gitignore` files end. File opened in Atom text editor.
By the way: The other entries above in the .gitignore file are copied from thisGitHub community project, in particular from the macOS and Swift files.

Third, let’s duplicate our secrets.json file in Finder (Xcode doesn’t support duplicating files AFAIK) and name it secrets.json.sample. This file is here to be checked into Git so others who checkout the project can easily duplicate it and remove the .sample extension without having to lookup which keys are actually needed. Of course, we have to remove the secrets from that file, I’ll replace it with some useful hint like <add secret here after duplicating this file & removing .sample ext>:

The `secrets.json.sample` file without any secret values, added to the project.

Fourth, we need to teach SwiftPM where to find our new JSON file so we can later access it in code. To do that, we just add a .copy entries to the resourcesparameter of our target in the manifest file. It’s enough to provide a relative path to the targets folder, which is BartyCrouchTranslatorTests in my case. The result looks something like this:

The `secrets.json` file added as resource in `Package.swift` manifest file.

But with this single resources entry, we’re getting a warning from Xcode because it finds our secrets.json.sample file in the package folder without knowing what to do with it.

Xcode warns when it finds resource files that are not declared in the Package manifest.

This could be either solved by changing our above entry from .copy("Secrets/secrets.json") to just .copy("Secrets") to accept all files within the Secrets folder. Or, what I find more correct, we can tell SwiftPM to explicitly ignore the .sample file by adding it to the exclude parameter:

The `exclude` entry in the Package manifest file to silence the warning.

Loading Secrets in Code

Now that we have our secrets.json resource file, let’s access it in Swift.

First, let’s create a new Swift file named Secrets.swift with our two keys as properties in a simple struct which conforms to Decodable:

import Foundation

struct Secrets: Decodable {
  let deepLApiKey: String
  let microsoftSubscriptionKey: String
}

Second, let’s implement some code that parses our secrets.json file. I prefer adding the functionality directly to our new Secrets struct as a static func:

import Foundation

struct Secrets: Decodable {
  let deepLApiKey: String
  let microsoftSubscriptionKey: String

  static func load() throws -> Self {
    let secretsFileUrl = Bundle.module.url(forResource: "secrets", withExtension: "json")

    guard let secretsFileUrl = secretsFileUrl, let secretsFileData = try? Data(contentsOf: secretsFileUrl) else {
      fatalError("No `secrets.json` file found. Make sure to duplicate `secrets.json.sample` and remove the `.sample` extension.")
    }

    return try JSONDecoder().decode(Self.self, from: secretsFileData)
  }
}

Please note that Bundle.module is only generated by the compiler if you actually have at least one resource added to your target. So if you get a compiler error, check that you have added the resources and that you actually have at least one resource file in your target like we did above.

Third and last, it’s time to access our secrets where we need them, in my case in the unit tests. Where I previously had a line like this because I didn’t want to commit my own key in the public repository:

let subscriptionKey = ""

I can now just load my key from the secrets.json file and access it like so:

let subscriptionKey = try! Secrets.load().microsoftSubscriptionKey

And that’s it, I have successfully accessed my secrets on my machine without checking them in to Git! You can find all the changes I did in my sample project in this single commit on GitHub.

Of course, everyone who wants to run my tests with proper keys needs to duplicate the .sample file and add proper secrets from now on. A next step for me could be documenting this in my README.md or CONTRIBUTING.md. Likewise, you might want to tell your team about it and even share a proper secrets.jsonfile for the project in a safe place, such as a password manager.

Extra: Setting up secrets on GitHub CI

Now that I’m loading secrets from a JSON file, I also want to configure my GitHub CI pipeline to use my secret keys when running the tests on CI.

Before getting started, let’s add the secrets to GitHub Actions using their secrets feature (see documentation here):

Adding the secrets to my GitHub repository.

To keep it simple, in the GitHub Actions workflow I’m just using the echocommand and create a file with the entire secrets.json file contents at the path where I expect it via >> path/to/secrets.json argument. The secrets are safely accessed via ${{ secrets.MICROSOFT_SUBSCRIPTION_KEY }}:

The full test GitHub Action CI job.

Now, on each CI run, the secrets.json file is configured before running tests.

And so my CI is also set up to access my secrets safely without leaking them.

👤
Want to Connect?
Follow me also on 👾 Twitch, on 🎬 YouTube and on 🐦 Twitter.