
Short answer: run the XCTest below on a macOS host whose signing identity matches the keychain-access-groups entitlement you're asserting. A Figment M-series Mac from $95/month (specific chip, RAM and disk confirmed in the quote) is the request-based path for Apple-silicon toolchains; the $29 virtual Intel macOS plan is a cheaper entry point that runs supported Intel Xcode (Xcode 26 or earlier) and can cross-compile and sign targets its toolchain supports.
A profile that was signed before you added a new access group will silently break Keychain calls at runtime — SecItemAdd returns a non-zero OSStatus instead of errSecSuccess, and your users lose saved credentials. The earlier CI catches that, the cheaper it is to fix. This page gives you a small Swift helper, an XCTest that drives the write/read/delete cycle, and a JSON XCTAttachment reviewers can read without rerunning the build. The authoritative constants are in Apple's Keychain Services reference and the Security framework result codes list.
The keychain-access-groups entitlement is an array of strings, each prefixed with your Team ID, naming the access groups your app may read and write. Apple's signing server embeds that same list in the provisioning profile. If the two drift — you added a group in .entitlements but reused last week's profile — your Keychain calls fail at runtime even though the binary links, launches, and passes UI tests.
Common mismatch modes worth asserting against:
| Mismatch | Symptom at runtime | Root cause |
|---|---|---|
| Group in entitlements, missing from profile | errSecMissingEntitlement on SecItemAdd | Profile not regenerated after adding the group |
| Group in profile, missing from entitlements | No error, items unreachable in that group | Entitlement file drifted from the portal |
| Wrong Team ID prefix | errSecParam (−50) | Hand-edited entitlement without matching prefix |
| Extension entitlement missing the shared group | Extension writes, container app can't read | Extension target has its own entitlements file |
Add groups through Signing & Capabilities → + Capability → Keychain Sharing in Xcode so the entitlement file and the profile regenerate together. Hand-editing the XML is the most common way this test starts failing.
Add this to your test target. It wraps the three SecItem calls and returns a Codable result you can both assert on and attach.
// KeychainTestHelper.swift (test target only)
import XCTest
import Security
struct KeychainTestResult: Codable {
let accessGroup: String
let writeStatus: Int32
let readStatus: Int32
let deleteStatus: Int32
var allPassed: Bool {
writeStatus == errSecSuccess &&
readStatus == errSecSuccess &&
deleteStatus == errSecSuccess
}
}
struct KeychainTestHelper {
/// Exercises a full write/read/delete cycle in `accessGroup`.
static func runCycle(accessGroup: String, label: String = "KeychainTestHelper") -> KeychainTestResult {
let testAccount = "xctest-\(UUID().uuidString)"
let testService = "com.figment.keychain-test"
guard let testData = "figment-ci-test-secret".data(using: .utf8) else {
fatalError("UTF-8 encoding failed")
}
let addQuery: [CFString: Any] = [
kSecClass: kSecClassGenericPassword,
kSecAttrService: testService,
kSecAttrAccount: testAccount,
kSecAttrAccessGroup: accessGroup,
kSecValueData: testData,
kSecAttrLabel: label,
]
let writeStatus = SecItemAdd(addQuery as CFDictionary, nil)
let readQuery: [CFString: Any] = [
kSecClass: kSecClassGenericPassword,
kSecAttrService: testService,
kSecAttrAccount: testAccount,
kSecAttrAccessGroup: accessGroup,
kSecReturnData: true,
]
var readResult: AnyObject?
let readStatus = SecItemCopyMatching(readQuery as CFDictionary, &readResult)
let deleteQuery: [CFString: Any] = [
kSecClass: kSecClassGenericPassword,
kSecAttrService: testService,
kSecAttrAccount: testAccount,
kSecAttrAccessGroup: accessGroup,
]
let deleteStatus = SecItemDelete(deleteQuery as CFDictionary)
return KeychainTestResult(
accessGroup: accessGroup,
writeStatus: writeStatus,
readStatus: readStatus,
deleteStatus: deleteStatus
)
}
}
// KeychainEntitlementTests.swift
import XCTest
final class KeychainEntitlementTests: XCTestCase {
// Replace with your real Team ID prefix and group — must match .entitlements exactly.
let accessGroup = "ABCDE12345.com.example.MyApp.shared"
func testKeychainCycleSucceeds() throws {
// Clean up anything a previous crashed run left behind.
let cleanup: [CFString: Any] = [
kSecClass: kSecClassGenericPassword,
kSecAttrService: "com.figment.keychain-test",
kSecAttrAccessGroup: accessGroup,
]
SecItemDelete(cleanup as CFDictionary)
let result = KeychainTestHelper.runCycle(accessGroup: accessGroup)
attachResult(result)
XCTAssertEqual(result.writeStatus, errSecSuccess, "SecItemAdd returned \(result.writeStatus)")
XCTAssertEqual(result.readStatus, errSecSuccess, "SecItemCopyMatching returned \(result.readStatus)")
XCTAssertEqual(result.deleteStatus, errSecSuccess, "SecItemDelete returned \(result.deleteStatus)")
}
private func attachResult(_ result: KeychainTestResult) {
guard let jsonData = try? JSONEncoder().encode(result) else { return }
let attachment = XCTAttachment(data: jsonData, uniformTypeIdentifier: "public.json")
attachment.name = "keychain-test-result.json"
attachment.lifetime = .keepAlways
add(attachment)
}
}
This code is an example, not a reported benchmark — run it against your own project and signing identity. After the run, open Xcode → Report Navigator, pick the test, and the attachment shows up under the test name:
{
"accessGroup": "ABCDE12345.com.example.MyApp.shared",
"writeStatus": 0,
"readStatus": 0,
"deleteStatus": 0
}
allPassed is a Swift-side convenience derived from the three status fields; it isn't encoded into the JSON.
| OSStatus | Constant | What it means here |
|---|---|---|
| 0 | errSecSuccess | Call succeeded |
| −34018 | errSecMissingEntitlement | Access group isn't in the signed entitlements (regenerate the profile) |
| −25299 | errSecDuplicateItem | A prior run died before delete — the cleanup step above clears this |
| −25300 | errSecItemNotFound | Nothing to read/delete — treat as failure if you expected a write first |
| −50 | errSecParam | Malformed query; usually a wrong kSecAttrAccessGroup string or missing Team ID prefix |
Numeric values track the Security framework result-codes page linked above; always confirm against the exact header for your SDK.
The iOS Simulator does not enforce Keychain access-group codesigning the way a real device or signed macOS binary does. SecItemAdd can return errSecSuccess on the Simulator even when the provisioning profile lacks the group. Tests that pass on Simulator but fail on device nearly always come down to this. Running the XCTest against a signed Mac target — with a Development or Developer ID certificate installed in the login keychain — exercises the same enforcement your App Store Connect build will see.
The $29 virtual Intel macOS VM is the cheapest way to run this suite when your toolchain is supported Intel Xcode (Xcode 26 or earlier). It's a real macOS environment — you drive Xcode and xcodebuild from the browser desktop, and outbound CI agents can reach your runner service. ARM-only binaries, Xcode 27 and visionOS targets need Apple silicon; pick an M-series plan from $95/month for those — the exact chip, RAM and disk are set when supply is confirmed, so ask for them in the quote rather than assuming an M4/16 GB configuration.
Worked cost sketch for a single-runner Keychain/signing CI node over one year:
| Plan | Monthly | 12 months | Fits this test when |
|---|---|---|---|
| Figment virtual Intel macOS | $29 | $348 | Project supports Intel Xcode 26 or earlier |
| Figment M-series (entry) | from $95 | from $1,140 | You need ARM execution, Xcode 27, or more RAM/cores (spec per quote) |
| Figment Managed Build Mac | $249 | $2,988 | You want CI setup, backups, monitoring and 30 minutes monthly support included |
Managed Build Mac is the one to pick if you'd rather we wire the self-hosted runner, keychain-unlock step and artifact retention, instead of writing that glue yourself. The 30 minutes of monthly support, backups and monitoring are the included scope; anything beyond that is scoped separately.