Figment.so
Mac hostingWebsite hostingGuidesHow to usePricing

macOS Keychain Entitlement Test for iOS Apps

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.

How keychain-access-groups and the Provisioning Profile Line Up

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:

MismatchSymptom at runtimeRoot cause
Group in entitlements, missing from profileerrSecMissingEntitlement on SecItemAddProfile not regenerated after adding the group
Group in profile, missing from entitlementsNo error, items unreachable in that groupEntitlement file drifted from the portal
Wrong Team ID prefixerrSecParam (−50)Hand-edited entitlement without matching prefix
Extension entitlement missing the shared groupExtension writes, container app can't readExtension 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.

The Keychain Test Helper

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
        )
    }
}

The XCTest and JSON Attachment

// 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.

Return Codes You'll Actually See

OSStatusConstantWhat it means here
0errSecSuccessCall succeeded
−34018errSecMissingEntitlementAccess group isn't in the signed entitlements (regenerate the profile)
−25299errSecDuplicateItemA prior run died before delete — the cleanup step above clears this
−25300errSecItemNotFoundNothing to read/delete — treat as failure if you expected a write first
−50errSecParamMalformed 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.

Why the Simulator Lies to You

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.

Picking a Figment Host for This Test

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:

PlanMonthly12 monthsFits this test when
Figment virtual Intel macOS$29$348Project supports Intel Xcode 26 or earlier
Figment M-series (entry)from $95from $1,140You need ARM execution, Xcode 27, or more RAM/cores (spec per quote)
Figment Managed Build Mac$249$2,988You 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.

Request a configuration →


Subscribe for Figma design and website publishing guides.


Figment.so

Contact

Twitter

Website pricing

Compute index

About

Privacy

Terms