Dominika Gajdova6 min

Storing secrets in iOS projects

iOSEngineeringFeb 3, 2026

iOSEngineering

/

Feb 3, 2026

Red tape with the words "Secrets", "Encrypted", "Obfuscation", and "Layered" crosses over an Apple logo on a dark background.
Dominika GajdovaiOS Engineer

Share this article

TL;DR 
Storing secrets in iOS apps is always a trade-off. The safest option keeps sensitive keys on the backend and out of the app entirely. When that’s not possible, iOS teams can still reduce risk through encrypted environment files, secure sharing, and lightweight obfuscation at build time. From backend proxies to encrypted environment files and obfuscation, learn what’s safe, what’s risky, and what actually helps. 

It’s a truth universally acknowledged that storing data securely on the frontend is close to impossible. It shouldn’t be done unless there’s no other option or the potential damage from leakage is low. That said, the right approach depends on the app you’re building and the infrastructure you can realistically support.

A secret is any security-sensitive string that should not be shared remotely unless encrypted and should not be easily visible in source code, either in plain text or compiled form.

Storing Keys on the Server

Ultimately, the safest option is storing sensitive keys on the backend and letting your server act as a middleman between your mobile app and the third-party service using that key. Unless you can rely on user authorization, this approach usually requires additional app validation.

Apple’s DeviceCheck API and App Attest service help here. They allow your backend to verify that incoming requests are in fact coming from your app. Secrets securely stay on the backend, and the keys never make it to the mobile app.

If running a proxy server isn’t feasible, the second-best solution is storing sensitive data in a cloud secrets manager. The client apps would then fetch these keys when needed and store them securely in the Keychain. A few examples of ready-to-use cloud solutions are GPC Secret Manager and AWS Secret Manager.

This backend-centered solution creates a single source of truth across platforms, so iOS and Android teams don’t need to invent their own ways of handling sensitive config keys. This approach also works well when developing with CI/CD pipelines.

  • Config keys are securely stored on the backend and saved to the keychain.
  • Config keys can still be intercepted using network inspection tools.

Storing Keys Locally 

If secrets must live inside the app, there are several approaches with varying levels of protection. The intention here is to make it as difficult as possible for the attacker to read your secrets if they decide to do so. It’s raising the effort required for someone using static or dynamic analysis of your reverse-engineered app binary.

Let’s start from the beginning.

Info.plist + xcconfig Injection Through Variables

The standard and naive solution is storing secrets in build configuration files (xcconfig) in combination with Info.plist variable injection, where each variable is replaced with the corresponding value from xcconfig during the build phase.

Example xcconfig:

// Configuration settings file format documentation can be found at:
// https://developer.apple.com/documentation/xcode/adding-a-build-configuration-file-to-your-project
SECRET_KEY = a83c5c3f-5881-44ae-82aa-a5e9b4ed971e

Info.plist

The problem is twofold:

  • Config keys are pushed to .git because they are stored in xcconfig files, which are part of the project source files. If the source code of your app ever leaks, your secrets are immediately exposed.
  • Config keys are resolved during the build phase and stored in Info.plist in plain text, making them easy to read in the compiled binary.

The first problem can be solved by git, ignoring the xcconfig files and sharing them locally. This could be executed in the form of a locally stored file with config declarations and a build phase script that copies these declarations to a corresponding xcconfig file.

Environment File

An alternative approach is storing these config variables outside of the project completely in a separate .env file. The structure of the .env file can be identical to the one used in xcconfig files: it’s a series of KEY = VALUE declarations. There can be multiple .env files defined for each environment, such as .env.development.

Environment File Encryption

Keeping these files outside of the git repository and sharing them locally or through a secure channel comes with a significant drawback: losing the history of changes. The answer lies in encrypting these environment files and storing them in an encrypted git repository. This approach also eliminates the need to reshare updated environment files with other team members, as they are already part of the git repository.

age is a simple encryption tool that is well suited for this use case. You can install it through the Homebrew package manager by calling brew install age. If you’re using Mise, you can install it through Mise as well. Using age, an asymmetric pair of public and private keys is generated.  This key pair is shared among team members using a password manager or another secure sharing method. age can be combined with another useful tool called sops, which makes editing encrypted environment files straightforward.

Start by creating a sops.yaml configuration in the project root:

creation_rules:

creation_rules:
  - path_regex: ${FOLDER_WITH_ENVIRONMENTS}/\.env\.(development|staging|production)$
    input_type: dotenv
    age: age1k3tx4dw9yrmeevgwjhnahf7thnmudpnrfptj33uux7p3xvhqn9ls4xahm3

The age parameter contains the generated age public key. Each user then needs to export a path to the private key so sops can work with it:

export SOPS_AGE_KEY_FILE="$HOME/.ssh/keys.txt"


Encryption and decryption are then as simple as:

sops --encrypt --in-place .env.development
sops --decrypt .env.development

sops even allows editing files in place. Running sops .env.development opens your default editor with encrypted secrets. Once you finish editing, save and close the editor, and sops will automatically encrypt the file. GPG is also an option if you require a more robust solution.

Obfuscation

At this point, secrets are safe in terms of source code leakage, but the issue with plain text still remains. To solve this problem and to make it harder for hackers trying to read secrets during static analysis, we're going to use an approach called GYB (Generate Your Boilerplate). This is script-generated source code that holds obfuscated secrets in an enum. The enum is regenerated each time an environment file changes. Of course, this file should be git-ignored.

The obfuscation method used is a classic XOR cipher combined with a custom salt. The resulting encrypted secrets can be stored either as byte arrays or base64-encoded strings. There is no security difference between these two, but base64 might be slightly slower and increases storage size by approximately 33%. The following code builds on a sample by Ethan Jackwitz.

let maxLength = longestSecretLength
var key = // Generate random bytes of size maxLength that will be used during XOR. 

func encrypted(_ text: String) -> Data {
    let text = Array(text.utf8)
    let key = Array(key)
    var encrypted: [UTF8.CodeUnit] = []

    for (offset, ch) in text.enumerated() {
        // Use XOR ^ operator to encrypt data with key.
        encrypted.append(ch ^ key[offset % key.count])
    }

    return Data(encrypted)
}

The script reads each KEY VALUE line and generates an enum holding encrypted secrets together with a method that decrypts the value of the encrypted secret during runtime.

enum BuildConfig {
    static let secretKey = decrypted("YraCn2FTiS8OKz5eDOHJ64RaXcMgEMq/")
}

private func decrypted(_ secret: String) -> String {
    guard let data = Data(base64Encoded: secret) else {
        // throw error or return empty string
    }
    
    guard let key = Data(base64Encoded: "EcPy+hMM+l9rSFc/YL66jucoOLd/e6/G6eFw/JyQdRD0umZYW7rZA7msmOEqJ8qIOM5N+autICUd5OtelsfASeIrl0ULx1rEyJiJDZh94L4TAH+8p/3KV7YUmHe8A78wiwc2AQ==") else {
        // throw error or return empty string
    }
    
    var result: [UTF8.CodeUnit] = []
        
    for (offset, character) in data.enumerated() {
        result.append(character ^ key[offset % key.count])
    }
    
    guard let secret = String(bytes: result, encoding: .utf8) else {
        // throw error or return empty string
    }
    
    return secret
}

// Example
let secret = "super_special_secret_key"
let encrypted = encoded(encrypted(secret)) // Results in secretKey encoded in base64
let decrypted = decrypted(encrypted) // Results back to "super_special_secret_key"

Setup Example

Let’s assume we have two environments: development and production.

  1. Create xcconfig files for each environment and set up schemes to run them.
  2. Create corresponding .env files, .env.development and .env.production each containing keys specific to that environment.
  3. Add environment file names to xcconfig definitions.
ENV_FILE = .env.development

Add a pre-compile build phase script that decrypts the .env file, runs the build_secrets script, and creates a BuildConfig.swift file based on the currently selected environment.

export PATH="/usr/local/bin:/opt/homebrew/bin:$PATH"
sops --decrypt "${SRCROOT}/environments/${ENV_FILE}" > "${DERIVED_FILE_DIR}/${ENV_FILE}"
swift -sdk $(xcrun --sdk macosx --show-sdk-path) "${SRCROOT}/build_secrets.swift" "${DERIVED_FILE_DIR}/${ENV_FILE}" "${SRCROOT}/project/BuildConfig.swift"
rm "${DERIVED_FILE_DIR}/${ENV_FILE}"


Files used during script execution need to be defined in the input and output files section of the build phase definition of this example. The following are these files:

Input files:

  • $(SRCROOT)/environments/.env.development
  • $(SRCROOT)/environments/.env.production
  • ${SRCROOT}/build_secrets.swift

Output files:

  • ${SRCROOT}/project/BuildConfig.swift
  • ${DERIVED_FILE_DIR}/.env.development
  • ${DERIVED_FILE_DIR}/.env.production

Now the BuildConfig.swift file will be regenerated each time the project is built if environment files have changed, and it will contain correct keys based on the currently selected scheme.

Config keys are not pushed to .git; environment files are shared securely using encryption.

Config keys are safer and harder to read in the code itself, as they are encrypted and not stored in plain text.
Obfuscation is the bare minimum and not bulletproof. Skilled hackers can still get access to these values by debugging your app on a jailbroken device.

Final Note

Stepping up your secrets storage approach is relatively simple and does not require third-party Swift dependencies. Sharing secrets within a team is straightforward using encryption tools such as age, sops, or GPG. We will never be able to fully prevent attackers from accessing secrets if those secrets are present in the app, even if they are encrypted. But doing something is better than doing nothing.

Share this article


Sign up to our newsletter

Monthly updates, real stuff, our views. No BS.