Bakken & BaeckTech

Hiding your Mobile Secrets | Ellen Shapiro

When you open-source a project, you want to avoid leaking API keys and other secret sauce to the public. Best case scenario, someone will use your API keys to create dozens of apps and you will get bombarded with emails about them. Worst case scenario, they can rack up an enormous bill or steal all your data.

I've been working on a Kotlin/Native framework (which allows a common shared codebase between iOS and Android) and its two companion apps as an experiment. As with most experiments I do, I wanted to keep it open-source, but I also wanted to be able to put crash reporting with Crashlytics in there so I could see quickly when things broke.

There is not a Kotlin/Native Crashlytics SDK, so I was going to need to install the iOS and Android SDKs at app level. I decided to use this as an excuse to research how to keep secrets on apps for each platform.

In the end, with the use of a

folder and some well placed environment variables on our Continuous Integration server, I was able to get the app building and running with Crashlytics on both platforms, in ways which would be reproducible even if we weren't using Kotlin/Native in the future.

If you only care about one platform, you can jump to reading about either Android or iOS, but the overall idea is to automate much of the following process:

  1. Pull anything secret for a particular platform out of your public-facing items and into a file.

  2. Stick that file into a git-ignored


  3. When running locally, load the ignored file.

  4. When running on CI, load values from environment variables.

  5. Take the secure values you retrieved from wherever, and put them into the file which needs those values.

  6. Finish your build.

  7. git reset
    the file which should not have secret values committed.


We'll start with the easier of the two platforms - Android.

Android's easier because it already uses the Gradle build system, which makes creating new things to plug into it relatively easy, once you figure out how to hook into the system.

When you go create an Android application through Firebase, you'll be asked to download a

file that has all the information Firebase needs to spin up its SDK. This needs to be placed in the root of your repository.

Unfortunately, this file has a number of sensitive API keys in it, which should not be committed to a public repository. So how do you take them out?

I made an
file and stuck it in the git-ignored
folder. A
file is a plaintext-ish file which a format which looks like this:


is stored as a string, so quotation marks are not necessary (and often are not actually wanted).

I then took all the values out of the

file which I did not want to be public, and replaced them with the string
, so that the JSON looked something like:

{ "project_info" : { "project_number": "REPLACE_AT_BUILD_TIME" }, "client": [ { "api_key" : [ "current_key": "REPLACE_AT_BUILD_TIME" ] } ] }

This makes it super obvious if what you're doing isn't actually working at build time - You'll get a crash if the values provided to the Firebase SDK are not in the proper format. This can also be committed to version control without worry.

I took the sensitive keys I'd removed and placed them into the

file I referred to earlier, which then looked something like this:


I decided to make a gradle Task to replace google services - that way I could set it up as a dependency for another task I knew would always run.

In the

at my Android project level, I added a new task below my

task replaceGoogleServices { }

To test that it was working, I gave it a very simple

block, which is what's executed as the last piece of the task you're setting up:

task replaceGoogleServices { doLast { println "REPLACE GOOGLE!" } }

and then below the task's declaration, I set it up as a dependency of

(which is run with pretty much any Android gradle command:

preBuild.dependsOn replaceGoogleServices

When that ran, I could see the

print out in the console, and I could confirm it worked. Next, I knew I wanted to add some additional methods, so I started like by trying to define some initial methods:

task replaceGoogleServices { def loadSecrets() { def secrets = loadSecretsFromProperties() if (secrets == null) { secrets = loadSecretsFromEnv() } assert(secrets != null) return secrets } def loadSecretsFromProperties() { // TODO } def loadSecretsFromEnv() { // TODO } doLast { println "REPLACE GOOGLE!" } }

The problem is that within a Gradle Task, you can't use the normal

syntax used to define a method. You must declare methods like so:

task replaceGoogleServices { ext.loadSecrets = { def secrets = loadSecretsFromProperties() if (secrets == null) { secrets = loadSecretsFromEnv() } assert(secrets != null) return secrets } ext.loadSecretsFromProperties = { // TODO } ext.loadSecretsFromEnv = { // TODO } doLast { println "REPLACE GOOGLE!" } }

You can still call your methods in exactly the same way as you would with

defined methods, you just have to set them up as closures.

Then, I fleshed out the method to load the secrets from a file:

ext.loadSecretsFromProperties = { def secretsPropertiesFile = rootProject.file(".secrets/") def secretsProperties = new Properties() try { secretsProperties.load(new FileInputStream(secretsPropertiesFile)) println "Loaded secrets from local file..." } catch (FileNotFoundException e) { // File was not found, try to load from env. return null } return secretsProperties }

If that failed, I'd try to load the same keys from the environment:

ext.getEnvironmentValue = { key -> return System.getenv().get(key, null) } ext.loadSecretsFromEnv = { def url = getEnvironmentValue("FIR_PROJECT_NUMBER") if (url == null) { return null } println("Loading secrets from env...") def secretsProperties = new Properties() secretsProperties.put("FIR_PROJECT_NUMBER", getEnvironmentValue("FIR_PROJECT_NUMBER")) secretsProperties.put("FIR_CURRENT_KEY", getEnvironmentValue("FIR_CURRENT_KEY")) return secretsProperties }

Once I had that down, I was able to go into the

method and use it (and Groovy's slightly insane JSON parsing library) to get the values from the
file into the JSON:

doLast { def secrets = loadSecrets() assert (secrets != null) def servicesJSONFile = rootProject.file('android/google-services.json') def parsedJSON = new JsonSlurper().parseText(servicesJSONFile.text) def builder = new JsonBuilder(parsedJSON) def projectNumber = secrets.getProperty("FIR_PROJECT_NUMBER") assert(projectNumber != null) builder.content.project_info.project_number = projectNumber def currentKey = secrets.getProperty("FIR_CURRENT_KEY") assert(currentKey != null) builder.content.client[0].api_key[0].current_key = currentKey // [You get the idea, more code omitted for brevity] servicesJSONFile.write(builder.toPrettyString()) }

After this ran, the JSON was updated to look something like:

{ "project_info" : { "project_number": "FirstSecureValue" }, "client": [ { "api_key" : [ "current_key": "SecondSecureValue" ] } ] }

Huzzah! The values were being replaced automatically. Now, all that was left was automatically resetting

so I didn't accidentally commit those values I'd gone through all this trouble to secure.

Figuring out how to get something to execute after a task is completed in gradle is a little bit harder than it seems on its face. However, I did find a workaround.

After some research I realized that the gradle task graph has an

closure, as well as a list of all the tasks it's performing.

Below the end of my

task, I added something to look for the last task so I could execute something after the last task had completed:

gradle.taskGraph.afterTask { Task task -> if (task == gradle.taskGraph.allTasks.last()) { println "DO THIS LAST!" } }

Once I verified that was executing where it needed to, after some time trying to use another

, I realized things would be much easier if I just used a
method, which had caused me so much problem in working with a Task before.

Once I realized that, it was simple to access the file and then reset it with a method, and then call that method when the task graph completed its last task:

def resetGoogleServicesFile() { println "Resetting services JSON" def servicesJSONFile = rootProject.file('android/google-services.json') "git checkout HEAD -- ${servicesJSONFile.path}".execute() } gradle.taskGraph.afterTask { Task task -> if (task == gradle.taskGraph.allTasks.last()) { resetGoogleServicesFile() } }

The full version of the

file I landed on can be found in our
repo here.


As with anything Apple makes, iOS's build system is a bit opaque. It's also somewhat difficult to hook into without shell scripting. I was interested in a solution which did not involve shell scripting, for the very technical reason that I am terrible at it, and screw it up constantly.

Swift provides an interesting twist to this, particularly with the use of the Swift Package Manager, often referred to as SPM.

Swift files can be turned into executable scripts, but you also can create small command line applications using SPM which allow you to take advantage of imported frameworks. SPM is built into the version of Xcode that will be out this fall, but that wasn't even announced when I started working on this, so I needed to do things in a bit more of a complex fashion.

I'd built some Swift scripting tooling using John Sundell's Marathon layer on top of SPM. I really loved Marathon's ease of setup, but it's designed more for things that don't need to run on every build of your app. It takes a second to spin everything it needs up, so if you're running it on every build, it can cause the build to stutter slightly.

In my previous use of Marathon, I'd wound up hacking together some shell scripts to determine if I needed to run Marathon to prevent the hiccup, but that seemed to defeat the point of trying to run scripts in Swift in the first place.

I decided to try to build my own command line application with the Swift Package Manager directly to see if I could remove the overhead for something I wanted to run every time I built my app.

Ironically, John Sundell's own work on Marathon is part of what got me up and running with this quickly: John's got a great guide to setting up a command line application which walked through some of the process of Marathon's setup.

I'll leave that link as the definitive guide to setting up a command line tool, as it's very thorough. I wound up also using a couple of small libraries John put together for scripting, Files, to make dealing with the File System simpler, and ShellOut, to make running Bash commands simpler.

There was a small twist in the data formats this time as well: Instead of Google handing me JSON, then my script loading from a platform-specific data storage type, it was the reverse.

Google hands you a

file where all of your keys are stored. I went through a similar process of taking out the sensitive keys and replacing them with garbage information such as
as I did with Android, but this time I put the sensitive keys into a JSON file which was stored in
, since JSON is super-easy to parse with Swift.

First, in the main bit of the script, I'd use

to grab access to the folder represented by
in the environment - the source root of the iOS app (checking to make sure it was finding the right folder), and pass it into a function I'd made to update the Plist:

let rootPath = try shellOut(to: ShellOutCommand(string: "echo $SRCROOT")) guard rootPath.hasSuffix("iOS") else { throw FileError.cantAccessSRCROOT } let rootFolder = try Folder(path: root) try rootFolder)

Similarly to the Android script, I'd check to see if there was a local file, and if there wasn't I'd try to load secrets from the environment. Here's what that function looks like:

enum Error: Swift.Error { case couldNotAccessGitRoot case noSecretsFileOrSecrets(String) } static func run(sourceRoot: Folder) throws { guard let gitRoot = sourceRoot.parent else { throw Error.couldNotAccessGitRoot } let secrets: [String: String] if self.exportJSONExists(gitRoot: gitRoot) { print("Secrets file exists locally, loading from that...") secrets = try self.loadSecretsFromJSON(gitRoot: gitRoot) } else { print("No local secrets file, attempting to load from environment variables...") secrets = self.loadSecretsFromEnvironment() guard !secrets.isEmpty else { throw Error.noSecretsFileOrSecrets("You don't have a secrets file or secrets set up in the environment - this ain't gonna work. Please make sure to set up secrets on CI." ) } } try self.updateFirebasePlistValuesCommands(sourceRoot: sourceRoot, secrets: secrets) }

One thing I particularly like about doing this in Swift is being able to add customizable errors. I wound up using a lot of

statements as well so I could see what was happening at certain levels on the CI server as well.

But when something went wrong, it's was really helpful to have a specifically typed error which eventually got printed out rather than a more generic exit code. It made debugging the whole thing much faster.


library made checking if a file exists very simple:

private static func exportJSONExists(gitRoot: Folder) -> Bool { do { let secretsFolder = try gitRoot.subfolder(named: secretsFolderName) return secretsFolder.containsFile(named: secretsFileName) } catch { debugPrint("Error checking if export script exists: \(error)") return false } }

I didn't want this function to

, but to simply return
if there was an issue, since that issue could be handled the same way as a nonexistent file would be handled: By trying to load the secrets from the Environment.

I was able to use

to cut down on code loading from JSON as well:

private static func loadSecretsFromJSON(gitRoot: Folder) throws -> [String: String] { let secretsFolder = try gitRoot.subfolder(named: secretsFolderName) let iosFile = try secretsFolder.file(named: secretsFileName) return try JSONLoader.loadStringJSON(from: iosFile) }

If for some reason either the JSON file didn't exist or couldn't be loaded, I'd try loading the secrets from the environment. Fortunately Swift has an extremely simple way to do this:

private static func loadSecretsFromEnvironment() -> [String: String] { let envDict = ProcessInfo.processInfo.environment return envDict.filter { key, _ in return key.hasPrefix("FIR_") } }

Then, once I had the secrets either from JSON or from the Environment, I made a wrapper for an Apple-provided tool called

(Note that link is to another site because Apple doesn't actually have any documentation for Plistbuddy beyond its
page) to replace replace data within the
I got from Google.

With that wrapper, I was able to quickly go through and update values for different secrets in the

using the data I'd loaded from either the JSON file or Environment variables:

private static func updateFirebasePlistValuesCommands(sourceRoot: Folder, secrets: [String: String]) throws { let iOSAppFolder = try sourceRoot.subfolder(named: "DoorbellBotNative") let plistFile = try iOSAppFolder.file(named: "GoogleService-Info.plist") let keysToUpdate = [ "AD_UNIT_ID_FOR_BANNER_TEST", "API_KEY", "CLIENT_ID", "DATABASE_URL", "GCM_SENDER_ID", "GOOGLE_APP_ID", "PROJECT_ID", "REVERSED_CLIENT_ID", "STORAGE_BUCKET" ] keysToUpdate.forEach { key in let secretKey = "FIR_\(key)" guard let secret = secrets[secretKey] else { print("No secret found for \(secretKey)") return } Plister.setValue(secret, for: key, in: plistFile) } plistFile) }

Unlike the Gradle code being able to handle everything for the Android app, I did have to add a bash script with one line at the very end of my build process for the iOS app:

git checkout HEAD -- "$SRCROOT/DoorbellBotNative/GoogleService-Info.plist"

Unfortunately, there's no real way to do this as part of the Swift Script without writing a second script - it must wait to be run until after the app has been built with the

that has all the secrets in it, and you can't tell the rest of the build steps to proceed until after your Swift script exits.

However, I'd now managed to get the amount of code written in untyped Bash down to a minimum, which was one of the primary goals of this exercise, so I figured that'd do for now.

If you're interested in seeing the whole mess, you can see the

project in the
repo, and the calls to it in the build phases of the main


It takes a little bit of banging your head on the desk to get tools doing exactly what you want them to do, but once a task like this is automated, you can share that automation throughout the projects which you work on.

Particularly when you use tools like the Swift Package Manager and Gradle that encourage the sharing of code and techniques, building something reusable across many projects can be both fun and super-useful.

And even if you're trying to build something cross-platform, you can still spend plenty of time noodling on something applicable to a single platform, and which can help you when building natively.