Subscribe to Jacob’s Tech Tavern for free to get ludicrously in-depth articles on iOS, Swift, tech, & indie projects in your inbox every week.
The AI gold-rush is in full swing.
And in a gold rush, baddies will steal shovels.
One deadly mistake is commonplace: bundling ChatGPT API keys directly into your shiny new AI app. This makes it trivial to steal your keys, burn through your credits, and rack up painful bills.
Today, I’ll explain a fundamental Security Law:
Don’t store API keys on the client.
Like, ever.
Today, we’re looking at the two main ways bad actors can sniff out your API keys; then we’ll learn how you can avoid this fate.
Decompiling Your App
If you’re storing API keys on your device, you might employ various protections mechanisms, with varying levels of sophistication. I’ll go through the most popular approaches in detail, and demonstrate how easy they are to circumvent.
Raw Strings in code
This is usually the first approach engineers take, prior to becoming securitypilled. When requesting data from an API, you’ll write something like this:
func callChatGPTAPI(prompt: String) async throws -> Data {
let url = URL(string: "https://api.chatgpt.com")!
var request = URLRequest(url: url)
let apiKey = "12345678-90ab-cdef-1234-567890abcdef"
request.addValue(apiKey, forHTTPHeaderField: "Authorization")
return try await URLSession.shared.data(for: request).0
}This code is compiled and delivered to users in the form of an .app bundle, containing all the executable code.
I’ve written before about how easy it is to get access to the .ipa file of any app on the App Store, which contains this .app bundle. This is the first port of call for bad actors looking to steal your keys.
If you want some light reading, you can check out Impress at Job Interviews by Inspecting their App Bundle now!
By following this approach, it’s trivial to get into the .app bundle. It’s just a folder, which looks a little like this:
The real juice lies inside Bev, the UNIX executable for my trusty, boozy side project. This contains all the compiled and linked code for my app.
With a built-in UNIX command, strings, we can inspect every raw string compiled into this executable.
strings BevNaturally, the output is pretty verbose:
As well as raw strings, you’ll also see a ton of symbol names for your compiled functions.
But near the bottom of this list, we see our prize:
12345678-90ab-cdef-1234-567890abcdefThe raw string for our API key, out in the open — easily searchable by any villain handy with a regex.
Info.plist
You might think to yourself, okay, I’m not going to put raw strings into my app where they can be easily decompiled.
So I’ll put them in the Info.plist where they’re meant to be.

Your API call site becomes a little more complex to retrieve this value.
func callChatGPTAPI(prompt: String) async throws -> Data {
let url = URL(string: "https://api.chatgpt.com")!
var request = URLRequest(url: url)
let apiKey = getAPIKeyPlist()
request.addValue(apiKey, forHTTPHeaderField: "Authorization")
return try await URLSession.shared.data(for: request).0
}
func getAPIKeyPlist() -> String? {
Bundle.main.object(forInfoDictionaryKey: "API_KEY") as? String
}There are a few sub-levels of sophistication here.
Rather than directly hardcoding keys, you might have multiple sets of keys, for development and prod. You can create
.xcconfigfiles for each environment, which resolve to values in theInfo.plistat build-time.If you’re a true security zealot, you’ll know never to commit secrets into source control. Perhaps you store your API keys securely on your CI/CD pipeline, injecting them into
.xcconfigorInfo.plistas part of your build scripts.
But all this is for naught.
Let’s take one more look at that .app folder, available to anyone with a MacBook, an iPhone, and too much time on their hands:
Ruh-roh.

Regardless of how they find their way into the Info.plist, these values are publicly available.
Obfuscated Strings
If you listen uncritically to everything you read on the internet, you might think you’re slick by obfuscating your strings. That’s certainly what I thought in 2019 when I first decided to be the resident security expert.
You can get very fancy with your obfuscation algorithm and your salting, but let’s be real: you’re going to ask ChatGPT to write a pair of functions to obfuscate and decode your keys. You might as well use up those credits before they get nicked.
private func obfuscate(key: String) -> String? {
let salt = "00000000-0000-0000-0000-000000000000"
guard let keyData = key.data(using: .utf8),
let saltData = salt.data(using: .utf8) else {
return nil
}
let obfuscatedData = zip(keyData, saltData).map { $0 ^ $1 }
let obfuscatedBase64 = Data(obfuscatedData).base64EncodedString()
return obfuscatedBase64
}
private func decode(obfuscated: String) -> String? {
let salt = "00000000-0000-0000-0000-000000000000"
guard let obfuscatedData = Data(base64Encoded: obfuscated),
let saltData = salt.data(using: .utf8) else {
return nil
}
let apiKeyData = zip(obfuscatedData, saltData).map { $0 ^ $1 }
return String(bytes: apiKeyData, encoding: .utf8)
}Getting the key at runtime is as simple as decoding the raw obfuscated string.
func callChatGPTAPI(prompt: String) async throws -> Data {
let url = URL(string: "https://api.chatgpt.com")!
var request = URLRequest(url: url)
let apiKey = getAPIKeyObfuscated()
request.addValue(apiKey, forHTTPHeaderField: "Authorization")
return try await URLSession.shared.data(for: request).0
}
func getAPIKeyObfuscated() -> String? {
decode(obfuscated: "AQIDBAUGBwgACQBRUgBTVFVWAAECAwQABQYHCAkAUVJTVFVW")
}






