Obfuscating .env Secrets and Embedding Them in iOS Apps with Kura, a Swift-Built CLI Tool

Obfuscating .env Secrets and Embedding Them in iOS Apps with Kura, a Swift-Built CLI Tool

Ruby dependencies in cocoapods-keys and Arkana made bundle install unstable on Xcode Cloud, so I built Kura, a pure-Swift CLI. It obfuscates .env secrets into type-safe Swift code. Here's how it works.
2026.07.06

This page has been translated by machine translation. View original

I've written several articles about secret management in iOS apps. Looking back, it has become something of a "history of the battle against Ruby dependencies."

In preparation for CocoaPods being archived (it is scheduled to become read-only in December 2026), I migrated from cocoapods‑keys to Arkana + SPM. But because Arkana is written in Ruby, I ended up being at the mercy of Xcode Cloud's system Ruby 2.6. I have hit the problem of bundle install failing whenever the major versions of Xcode and macOS are out of sync twice now.

I had also considered not using the system Ruby in the first place, but it would add about 5 minutes to the build, so I didn't adopt it.

So this time, with the idea of cutting the Ruby dependency itself, I built a pure-Swift CLI tool called Kura. It obfuscates the secrets written in .env using XOR + salt and generates type-safe Swift code, and it works on Xcode Cloud as is. In this article, I introduce how Kura came about and how to use it.

Background: Why "Ditch Ruby"?

First, let me briefly summarize the issues covered in my past articles. They lead directly to the motivation for choosing Kura.

cocoapods‑keys and its end

Originally I used cocoapods‑keys, writing only the key names in the Podfile and keeping the values in the local Keychain, so that nothing was hardcoded in the source. However, cocoapods‑keys is a CocoaPods plugin, and once the CocoaPods Specs repository becomes read-only in December 2026, it will no longer receive updates.

Another downside is that using CocoaPods prevents you from using the folder-based format for Xcode projects.

Migrating to Arkana + SPM, and a new concern

So I migrated to Arkana, which is also recommended in the cocoapods‑keys README. Arkana is a CLI with SPM support, and it lets you manage secrets with two files: .arkana.yml and .env. I got away from the CocoaPods dependency, but since Arkana is written in Ruby, gems have to be resolved from RubyGems at build time.

This is where the Xcode Cloud system Ruby 2.6 problem surfaced. The latest Arkana (1.5.0 and later) requires Ruby 2.7 or later, so on a Ruby 2.6 environment, 1.4.0 is the upper limit. The CI script also became fairly complicated, for example installing Bundler in the user area on Xcode Cloud and then running bundle install.

Builds failed because the Xcode and macOS versions did not match

Furthermore, when building native extensions with system Ruby 2.6, I ran into a problem where the build fails due to an SDK path mismatch if the major versions of Xcode and macOS do not match.

  • First time: With the combination of Xcode 16.4 + macOS 26.0, as soon as Xcode Cloud switched its default environment to macOS 26.0, the CocoaPods installation failed (it looked for MacOSX15.5.sdk, which doesn't exist)
  • Second time: It happened again with Xcode 27 beta 1 + macOS 26.4. MacOSX27.0.sdk only contains the Ruby 2.6 headers for universal-darwin26, and the universal-darwin25 that macOS 26.4 (darwin25) looks for is missing, so building the json gem failed

And then a problem occurred with the connection between Xcode Cloud and the RubyGems server

From June 23 to 25, 2026, gem install bundler and bundle install started failing on Xcode Cloud. Builds failed 9 times out of 11 attempts.

The root cause of all of these is the same: CocoaPods and Arkana have a system dependency on Ruby. For the time being it can be avoided by "aligning the major versions of Xcode and macOS," but as long as we depend on Ruby, we can't fully escape this kind of problem.

I have already moved away from CocoaPods and install libraries via SPM. The only thing that needs Ruby is generating secrets.

So the starting point for Kura was the idea of removing the Ruby dependency from the secret generation tool entirely.

What is Kura?

Kura (蔵, "storehouse") is a pure-Swift CLI tool (kura-generator) that obfuscates the secrets in a .env file using XOR + salt and generates type-safe Swift code.

  • No external runtime such as Ruby is needed. It is self-contained with SwiftPM alone
  • Since it is a plain SwiftPM executable, you don't have to struggle with Xcode project or plugin sandboxes
  • It works with Xcode Cloud as is via ci_post_clone.sh
  • It handles both global secrets and per-environment secrets (debug/release, etc.)
  • It can read some options from .arkana.yml, which makes migrating from Arkana easy

Because it doesn't go through Ruby, neither network connections to the RubyGems server nor SDK path mismatches caused by Xcode/macOS version mismatches can occur in the first place. The key point is that the structural causes of the CI problems stemming from the Ruby runtime environment described above are removed altogether.

I also considered existing options. For example, I tried using a custom Ruby version on Xcode Cloud (related article), but it extended build time by about 5 minutes, so I decided against it. I also thought about modifying existing tools like CocoaPods or Arkana, but both are designed around the Ruby runtime itself, so partial modifications could not fundamentally solve this problem. That's why I decided to start over with a design that doesn't go through Ruby at all, and that became Kura.

Setup steps

From here, we'll actually introduce it into an iOS app project. There are four main steps.

  1. Create BuildTools/Package.swift and add Kura
  2. Create .kura.yml in the project root
  3. Create .env in the project root
  4. Run the generator and add the generated KuraKeys/ to the app as a local package

1. Add Kura to BuildTools

We recommend not adding Kura to the app's own Xcode project. Create a dedicated BuildTools/Package.swift and keep it contained there. This prevents Yams and other internal dependencies of Kura from getting mixed into the app's dependency graph (Package.resolved). It's the same idea as isolating build tools such as SwiftFormat in BuildTools/.

I chose this design because when I had integrated it directly into an Xcode project, the internal dependency Yams got mixed into the app's Package.resolved and made the dependencies harder to follow. To avoid repeating that mistake, Kura itself is structured so that it can be isolated on the BuildTools side.

Create a BuildTools/ directory in the project root and place the following Package.swift in it.

BuildTools/Package.swift
// swift-tools-version:5.9
import PackageDescription

let package = Package(
    name: "BuildTools",
    platforms: [.macOS(.v13)],
    dependencies: [
        .package(url: "https://github.com/CH3COOH/Kura.git", from: "1.0.2"),
    ],
    targets: [
        .target(
            name: "BuildTools",
            dependencies: [
                .product(name: "kura-generator", package: "Kura"),
            ],
            path: ""
        ),
    ]
)

For the target, place one empty Swift file inside BuildTools/.

$ touch BuildTools/Empty.swift

2. Create .kura.yml

Create the configuration file .kura.yml in the project root. The following is an example that explicitly specifies all the main options. If the default values are fine, you can skip ahead to "If you use the default settings" below.

.kura.yml
import_name: KuraKeys      # Name of the generated Swift module (default: KuraKeys)
result_path: .             # Directory where KuraKeys/ is output (default: current directory)
swift_declaration: public  # public / internal (default: public)
                           # The generated output is a standalone package, so public is required if the app imports it.
                           # Choose internal only if you copy the generated .swift directly into the app target.
preserve_key_case: false   # If true, key names are used as property names as is, without camelCase conversion (default: false)

global_secrets:
  - API_KEY
  - ANALYTICS_TOKEN

environments:
  debug:
    - DEBUG_ENDPOINT
  release:
    - RELEASE_ENDPOINT

Write values that are shared across all environments under global_secrets, and values you want to switch per environment under environments.

Unless explicitly specified, the configuration file is searched for in the order .kura.yml → .kura.yaml → .arkana.yml. When migrating from Arkana, you can also have it read your existing .arkana.yml, but only the supported options are applied (see the migration section below for details).

If you use the default settings

Since import_name / result_path / swift_declaration / preserve_key_case all work with their default values when omitted, if the defaults are fine, you only need to define the keys you want to use, as follows.

.kura.yml
global_secrets:
  - API_KEY
  - ANALYTICS_TOKEN

environments:
  debug:
    - DEBUG_ENDPOINT
  release:
    - RELEASE_ENDPOINT

3. Create .env

For local development, place a .env in the project root.

.env
API_KEY="your_api_key_here"
ANALYTICS_TOKEN="your_token"
DEBUG_ENDPOINT="https://dev.example.com"
RELEASE_ENDPOINT="https://example.com"

After completing the steps so far, the project root should have a file structure like the following.

File structure of the project after creating BuildTools, .kura.yml, and .env

I want to avoid leaving .env on disk

The .env containing secrets must be added to .gitignore. As with Arkana, the rule is not to commit .env.

However, it isn't enough just to not commit it. Ideally, we'd like to avoid leaving .env on disk in plaintext in the first place. With 1Password Environments or the 1Password CLI (op), you can virtually mount .env with values from 1Password, so you don't have to write the actual secret values to disk. For details, please refer to the article I wrote previously.

To keep things simple, this article proceeds on the assumption of a regular .env.

4. Run the generator

In the directory containing .kura.yml / .env (the project root), run the following.

$ swift run --package-path BuildTools kura-generator

--package-path specifies "which package to build with," but the generated executable runs in the current directory at runtime. Therefore, the relative paths for .kura.yml / .env are resolved as is, and it works without specifying any options explicitly.

On success, a local Swift package named KuraKeys/ (the name specified by import_name) is generated.

The KuraKeys package generated by kura-generator

Add this to the app as a local package. In Xcode, the flow of specifying the inner KuraKeys folder via File → Add Packages → Add Local… is the same as when you added Arkana's ArkanaKeys.

// The app's Package.swift, or Xcode's Package Dependencies
.package(name: "KuraKeys", path: "KuraKeys")

Using the generated code

The generated output is Swift code like the following (the byte sequences and salt actually contain random values).

KuraKeys/Sources/KuraKeys/KuraKeys.swift
// AUTO-GENERATED by Kura — DO NOT EDIT

private func _kuraDecrypt(_ encoded: [UInt8], salt: [UInt8]) -> String { ... }

public enum KuraKeys {
    public enum Global {
        public static var apiKey: String { ... }
        public static var analyticsToken: String { ... }
    }
    public enum Debug {
        public static var debugEndpoint: String { ... }
    }
    public enum Release {
        public static var releaseEndpoint: String { ... }
    }
}

global_secrets are placed under Global, and per-environment secrets are nested under an enum named after the environment in PascalCase (Debug / Release, etc.). From the app, you can reference them in a type-safe way as follows.

import KuraKeys

let key = KuraKeys.Global.apiKey
let endpoint = KuraKeys.Release.releaseEndpoint

With Arkana you referenced values through an instance, like ArkanaKeys.Global().apiBaseUrl, but Kura uses static properties on an enum, so you can write KuraKeys.Global.apiKey without creating an instance. That is a slight difference.

Key name conversion rules

By default, the key names in .kura.yml are converted to camelCase property names.

Key in .kura.yml Generated property name
API_KEY (underscore-separated) apiKey
APIKEY (no separators) apikey (lowercased)

With preserve_key_case: true, the conversion is skipped and the key name is used as is for the property name (API_KEY → API_KEY). This is handy when you want to migrate without changing existing generated property names.

Migrating from Arkana

If you're already using Arkana, you can have Kura read your .arkana.yml as is, but Kura only interprets the supported options. There are some points to be aware of when actually migrating.

  • import_name and global_secrets can be read as is. If you only use global keys, you can migrate almost unchanged.
  • Per-environment secrets need to be written differently. Arkana's .arkana.yml was structured with environments (a list of environment names) + environment_secrets (a list of keys), but Kura interprets environments as a dictionary of "environment name → array of keys." So on the Kura side, rewrite it as follows.
# Arkana format (example)
environments:
  - Debug
  - Release
environment_secrets:
  - apiKey

# ↓ Rewritten in Kura format
environments:
  debug:
    - DEBUG_API_KEY
  release:
    - RELEASE_API_KEY
  • If you want different values per environment, separate the key names. Arkana read apiKeyDebug / apiKeyRelease from .env separately based on the single key apiKey. Kura looks up the key names in .env / environment variables as they are, so if you want different values per environment, the design is to prepare separate key names such as DEBUG_API_KEY / RELEASE_API_KEY as above.

If your setup centers on global keys, migration is very easy, and only projects that provide different values per environment need to be mindful of this difference.

Configuration on Xcode Cloud

This is where switching from a Ruby-based tool really pays off.

When I previously ran Arkana on Xcode Cloud, to deal with the system area being read-only, I wrote a CI script that pointed GEM_HOME at the user area, installed Bundler, and then ran bundle install → bundle exec arkana. I also had to pay attention to unstable connections to the RubyGems server and the effort of aligning Xcode and macOS versions. Since Kura is pure Swift, you don't have to worry about any of that.

Also, when resolving secrets, Kura prioritizes environment variables over the .env file, so simply registering each key as an Environment Variable in the workflow is enough for the values to be resolved (this behavior of "referencing environment variables on CI" follows the same idea as cocoapods‑keys and Arkana).

Just running kura-generator in ci_scripts/ci_post_clone.sh creates the KuraKeys package.

ci_scripts/ci_post_clone.sh
#!/bin/sh
set -e

cd "$CI_PRIMARY_REPOSITORY_PATH/path/to/project"  # Move to the location of .kura.yml
swift run --package-path BuildTools kura-generator

With this, you no longer have to suffer from Xcode/macOS version mismatches on Xcode Cloud or unstable connections to the RubyGems server.

Operational tips

The generated KuraKeys.swift uses a random salt on every run, so its contents change every time even if the contents of .env are the same. As an operational policy, adding the generated KuraKeys/ to .gitignore and regenerating it each time helps avoid pointless diffs and conflicts. On Xcode Cloud it is generated every time by ci_post_clone.sh, so it's fine as is.

Summary

Migrating from cocoapods‑keys to Arkana + SPM removed the CocoaPods dependency, but as long as the Ruby dependency remained, I couldn't escape CI instability on Xcode Cloud caused by Xcode and macOS version mismatches and unstable connections to the RubyGems server.

Building Kura let me sort things out as follows.

  • With a pure-Swift mechanism that doesn't depend on Ruby / RubyGems, troubles caused by the Ruby runtime environment on CI no longer occur structurally
  • It's unaffected by the end of CocoaPods (it's SPM-based)
  • Secrets written in .env can be referenced as type-safe Swift code
  • It's easy to migrate from Arkana's configuration and operations

On the other hand, the fact that it is obfuscation and not encryption hasn't changed. It's effective for the purpose of reducing plaintext exposure from the binary, but for values that truly need to be kept secret, please consider managing them outside the client, such as with a backend proxy. Also, if you don't want to keep .env on disk, please consider combining it with something like 1Password Environments.

I hope this serves as a reference for the next step for those struggling to move away from cocoapods‑keys, or with CI instability caused by Arkana's Ruby dependency. It's a freshly built tool and there are probably many rough edges, so I'd appreciate your feedback.

The Kura repository is publicly available below.

https://github.com/CH3COOH/Kura

The articles I've written while exploring how to move away from CocoaPods and cocoapods‑keys are as follows.

For handling .env, please also refer to the following article.

Share this article