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.
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:
- Check in a
secrets.json.sample
file into Git with the keys, but no values - Let developers duplicate it , remove the
.sample
extension & add values - Ignore the
secrets.json
file via.gitignore
so it’s never checked in - Provide a simple
struct
conforming toDecodable
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:
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:
By the way: The other entries above in the .gitignore
file are copied from this GitHub 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>
:
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 resources
parameter 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:
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.
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:
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.json
file 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):
To keep it simple, in the GitHub Actions workflow I’m just using the echo
command 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 }}
:
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.
A native Mac app that integrates with Xcode to help translate your app.
Get it now to save time during development & make localization easy.