Use when wiring a value that ships in the binary but must stay out of public-repo diffs until launch (AdMob `GADApplicationIdentifier` / banner unit ID, a third-party SDK app key) into an Apple build via xcconfig, Info.plist `$()` substitution and a guarded `Bundle.main` read, including `ci_post_clone.sh` generation on Xcode Cloud; or when CLI tooling reads an ASC `.p8` / key ID from `secrets/.env`. Not for signing certs, CloudKit or APNs keys, nor leak prevention (gitleaks, lefthook, Secret ...
Scanned 9/19/2026
Install to Claude Code
npx -y skills add wei18/apple-dev-skills --skill build-time-secret-injection --agent claude-codeInstalls into .claude/skills of the current project.
Are you the author of Build Time Secret Injection?
Add the live security badge to your README — it updates automatically with every re-scan.
[](https://www.skillsdirectory.com/skills/wei18-build-time-secret-injection)More formats (shields.io, HTML) on the badges page.
---
name: build-time-secret-injection
description: Use when wiring a value that ships in the binary but must stay out of public-repo diffs until launch (AdMob `GADApplicationIdentifier` / banner unit ID, a third-party SDK app key) into an Apple build via xcconfig, Info.plist `$()` substitution and a guarded `Bundle.main` read, including `ci_post_clone.sh` generation on Xcode Cloud; or when CLI tooling reads an ASC `.p8` / key ID from `secrets/.env`. Not for signing certs, CloudKit or APNs keys, nor leak prevention (gitleaks, lefthook, Secret Scanning) — see apple-public-repo-security. SDK isolation is monetization-sdk-integration.
---
# Build-time Secret Injection (Apple-platform)
**Tuist assumption**: the `Project.swift` snippets below assume the app's `.xcodeproj` is generated by Tuist from a root-level `Project.swift` (Tuist's own convention keeps `Tuist/` for `Package.swift` and shared helpers, not for `Project.swift` itself). A hand-maintained `.xcodeproj` needs no `Project.swift` step — see **Non-Tuist projects** below for the equivalent (an xcconfig referenced directly from the target's Build Settings → Configurations, instead of via `Project.swift`).
## When to invoke
Any task that introduces or wires values which are:
- Technically **app-public** once the app ships (embedded in `Info.plist`, visible in shipped binary, observable in network traffic), AND
- **Pre-launch sensitive** (committed to public repo before ship = ad-fraud reconnaissance window, convention violation among collaborators, or fingerprinting of unreleased product)
Examples:
- AdMob App ID + Banner / Interstitial / Rewarded Unit IDs
- ASC API `.p8` key, key-id, issuer ID, ASC numeric app-id
- Any third-party SDK app key (Firebase, RevenueCat, etc.) where the convention is "hold until ship"
Do NOT invoke for:
- True per-deploy secrets (signing certs, CloudKit production API keys, push notification keys) — those have stricter patterns (see `apple-public-repo-security`)
- Values genuinely public from day 1 (bundle IDs, CKContainer IDs, IAP product IDs, marketing URLs)
## The pattern
| Value's consumer | Layer | Storage |
|---|---|---|
| Xcode build / Info.plist / `Bundle.main` read | Layer 1 — xcconfig | `Tuist/<Domain>.xcconfig` (gitignored) |
| CLI tooling (`swift run <CLI>`, shell scripts) | Layer 2 — `.env` | `secrets/.env` (gitignored) |
| Signing certs, CloudKit server-to-server key, APNs key | Not this skill | → `apple-public-repo-security` |
### Two storage layers, one mechanism per layer
**Layer 1 — Build-time secrets (consumed by Xcode build process)**
```
Tuist/
├── <Domain>.xcconfig # gitignored, real values
├── <Domain>.xcconfig.example # committed, sandbox values + structure
├── Signing.xcconfig # existing precedent (gitignored)
└── Signing.xcconfig.example # existing precedent (committed)
```
- xcconfig holds `KEY = VALUE` pairs
- `Project.swift` declares per-target `settings(configurations: [.debug(name:, xcconfig:), .release(name:, xcconfig:)])` pointing at the file
- Info.plist uses `$(KEY)` substitution to embed values at compile time — e.g. `ADMOB_APP_ID = ca-app-pub-<publisher-id>~<app-id>` in the xcconfig and `<key>GADApplicationIdentifier</key><string>$(ADMOB_APP_ID)</string>` in Info.plist (`GADApplicationIdentifier` is the key the Google Mobile Ads SDK reads at startup; `ADMOB_APP_ID` / `ADMOB_BANNER_UNIT_ID` are this project's own xcconfig names)
- App code reads via `Bundle.main.object(forInfoDictionaryKey: "...")` — guarded against nil / empty / unresolved `$()` token
- **CI side** (`ci_scripts/ci_post_clone.sh`): reads XCC env vars (stored as Secrets in ASC → Xcode Cloud → Workflow → Environment Variables) and generates the xcconfig file before `tuist generate` runs
**Layer 2 — CLI tooling secrets (consumed by `swift run <CLI>` etc.)**
```
secrets/
├── .env # gitignored, real values
├── .env.example # committed, structure + docstring
├── <Domain>AuthKey_*.p8 # gitignored binary cert
└── .gitignore # deny-by-default: */!*.example/!README.md/!example/ /!example/**
```
- `.env` is `KEY=VALUE` shell-style
- Dev pattern: `source secrets/.env && swift run <CLI> --flag-using-$KEY ...`
- CLI itself does NOT need code changes to read env automatically
### Project.swift wiring (Tuist)
```swift
let appTarget = Target.target(
// ...
settings: .settings(
base: ["SWIFT_VERSION": "6"],
configurations: [
.debug(name: "Debug", xcconfig: "Tuist/Config-Debug.xcconfig"),
.release(name: "Release", xcconfig: "Tuist/Config-Release.xcconfig"),
]
)
)
```
Wrap multiple xcconfigs via a `Config-{Debug,Release}.xcconfig` that `#include?` both Signing + AdMob (Tuist's `xcconfig:` arg takes a single path).
### Multi-app dispatch in `ci_post_clone.sh`
When one repo ships multiple app schemes (e.g. AppA + AppB), XCC sets `$CI_PRODUCT` and `$CI_XCODE_SCHEME` per workflow. For the case-switch that picks the right env-var prefix per scheme, read `references/multi-app-ci-dispatch.md`.
### Non-Tuist projects
If the project uses a hand-edited `.xcodeproj`, the equivalent storage is `Config/*.xcconfig` referenced via target → Build Settings → Base Configuration. Pattern is otherwise unchanged. Tuist regen / clobbering concerns don't apply; manual sync remains your responsibility.
### Smoke test scope (CRITICAL)
The substitution-resolution check must run against the **built bundle's** Info.plist, not the source-tree Info.plist:
```swift
// ❌ WRONG — reads source plist, gets literal "$(ADMOB_BANNER_UNIT_ID)" — passes falsely
// (ADMOB_BANNER_UNIT_ID is this project's own xcconfig key name, not one Google defines —
// see the multi-app xcconfig rendering above.)
let plist = try PropertyListSerialization.propertyList(from: sourceData, ...)
#expect((plist["ADMOB_BANNER_UNIT_ID"] as? String)?.isEmpty == false) // passes for "$(...)" string
// ✅ RIGHT — combine source-plist key-presence test + runtime guard in code
// Source test catches "someone deleted the key"; runtime guard catches "substitution failed"
guard
let bannerID = Bundle.main.object(forInfoDictionaryKey: "ADMOB_BANNER_UNIT_ID") as? String,
!bannerID.isEmpty,
!bannerID.hasPrefix("$(")
else { preconditionFailure("...") }
```
Consider adding a build-phase script that asserts no `$()` literals survived substitution into the built `.app/Info.plist`; until one exists, the runtime guard above is the only catch.
## Anti-patterns to refuse
1. **Production IDs in code comments, docstrings, PR descriptions, commit messages, or `Info.plist <!-- -->` blocks.** Even when the value field uses a sandbox stand-in, the surrounding prose leaks production via git history. **Including the literal ID anywhere in tracked text — even prefixed by TODO / FIXME / "will-replace" — IS the leak.** Reference the out-of-repo vault entry or the gitignored secrets file by name; never paste the value inline.
2. **Hardcoded production IDs in `Live.swift` with intent to "swap before release"** without an enforcement mechanism. The interim `fatalError("REPLACE_BEFORE_RELEASE: ...")` pattern is acceptable as a TRANSITIONAL guard paired with xcconfig migration, but is forbidden as a long-term standalone solution. Once xcconfig is in place, replace with: Info.plist `$()` + runtime guard verifying `Bundle.main.object(forInfoDictionaryKey:)` returns non-empty AND non-`$(...)`.
3. **Conflating GitHub Secrets with XCC env vars.** Apple's XCC does not read GH Secrets — they're separate storage. If CI builds on XCC, secrets must live in XCC's Environment Variables UI, not GH.
4. **Most common mistake**: ❗ **Shell env vars do NOT feed xcconfig `$(VAR)` interpolation.** xcconfig variable resolution reads from the build settings table, not process env. `source admob.env && xcodebuild archive` does NOT populate `$(ADMOB_APP_ID)`. Only positional `xcodebuild VAR=value` or `-xcconfig override.xcconfig` actually injects, OR a CI script writes the xcconfig file before build.
5. **`Bundle.main.object(forInfoDictionaryKey:) as! String`** — force cast bypasses SwiftLint AND crashes hard if CI generation skipped + xcconfig missing. Use `as? String` + `guard let ... else { preconditionFailure }` with the unresolved-`$()` check.
6. **`Bundle.main` from inside a SwiftPM package** is fine for app-target composition root reads but flaky for #Preview / test host / unit-test contexts. Wrap reads in a smoke test that asserts the key exists in source plist; runtime guard compensates for missing-substitution case.
7. **`secrets/` or `Tuist/<Domain>.xcconfig` committed by accident.** Use an inner `secrets/.gitignore` deny-list (`* / !*.example / !README.md / !example/ / !example/**` — the last two are required, otherwise `*` ignores the `example/` directory and git never descends into it) PLUS root `.gitignore` rules `Tuist/*.xcconfig` + `!Tuist/*.xcconfig.example` so neither slips through default-add operations.
8. **Tuist `tuist generate` silently clobbering unmanaged xcconfigs.** If `Tuist/<Domain>.xcconfig` exists but is NOT referenced in `Project.swift`'s `.settings(configurations:)`, Tuist regen drops it from the project. Verify Project.swift wiring before assuming xcconfig is active.
## Verification checklist
Use this both when adding a new secret value and when auditing an existing implementation.
- [ ] Decide the layer: Xcode build / Info.plist / `Bundle.main` read → Layer 1 xcconfig; `swift run` / CLI scripts / shell → Layer 2 `secrets/.env`
- [ ] Root `.gitignore` has `Tuist/*.xcconfig` + `!Tuist/*.xcconfig.example`; `secrets/.gitignore` inner deny-list present (`* / !*.example / !README.md / !example/ / !example/**`) — `git check-ignore -v secrets/example/README.md` reports nothing
- [ ] KEY is added to the appropriate `.example` file with a sandbox/test default value, with an inline comment naming the out-of-repo vault entry that holds the real value (password manager / team vault) — never the literal value
- [ ] `Project.swift` per-target `.settings(configurations:)` references the xcconfig
- [ ] Layer 1: `Info.plist` uses `$(KEY)` substitution for each secret; app code reads via `Bundle.main.object(forInfoDictionaryKey:)` with a guard (NOT `as!`) that rejects `nil`, empty, and the `$(...)` literal
- [ ] Smoke test reads the source plist for a key-presence assertion
- [ ] `ci_post_clone.sh` writes the xcconfig from the XCC env var (`${VAR:?missing message}`) BEFORE `tuist generate`; if multi-app, `case` on `$CI_XCODE_SCHEME` selects per-app env vars — see `references/multi-app-ci-dispatch.md`
- [ ] XCC Workflow Environment Variables UI lists each KEY (per scheme if multi-app), marked Secret
- [ ] `grep -r "<real-prod-value>" .` (excluding gitignored dirs) returns zero hits across all tracked files
- [ ] The real value is recorded in the out-of-repo secret store, noting which entry holds it — never in a tracked file
## Related skills
- **REQUIRED background**: `apple-public-repo-security` — broader secret-leak prevention (gitleaks, lefthook, GitHub Secret Scanning)
- **SIBLING**: `monetization-sdk-integration` — invoke together when wiring AdMob; this skill is the secret-handling layer
- **SIBLING**: `asc-api-automation` — ASC API key handling (the `.p8`) once the key leaves the build and drives the REST API
- Official sources: when verifying or updating a factual or version-sensitive claim, read `references/official-docs.md`.
Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.
No comments yet. Be the first to comment!