At JNUC I talked about what happens after a secret lands in a repo. The better question is how to keep secrets out of scripts in the first place. Anything a script can read, it can leak: a stray set -x, a debug log, a pasted terminal session.
macOS 27 ships a framework that offers a different shape: ManagedApp. An MDM delivers a credential through Declarative Device Management (DDM), and a correctly signed app or extension can ask for it by identifier. The secret is scoped to that signed code instead of to whatever shell happens to be running.
I built a small proof of concept to find out whether that is useful for the real problem: an MDM-run script needs to make an authenticated API call, and I would rather it never see the token. Apple labels these APIs preliminary, so treat everything here as a test report, not a recommendation.
The Boundary I Wanted
The design goal is a capability, not a credential. A script can ask for a named operation, and something signed performs it:
workflowctl ping
│ XPC request: a named operation, nothing else
▼
signed broker (embedded XPC service)
│ ManagedApp hands over the secret immediately before use
▼
HTTPS API
The CLI gets back an HTTP status and a request ID. It has no way to ask for the token, because no such operation exists. The contract file says so directly:
/// Operations a script may request. Adding an operation is a security review.
enum BrokerOperation: String, Codable {
case ping
case systemPing
}
struct BrokerReply: Codable {
let statusCode: Int
let requestID: String?
}
The README for the project makes the same point as a rule: the broker “must never grow a getSecret API.”
Step One: The App
Before involving the XPC layer, I built a small signed SwiftUI app with two interchangeable secret sources behind one protocol. A Development source reads POC_API_KEY from the Xcode scheme environment, so I could build the UI and the request flow without any MDM. A ManagedApp source calls Apple’s provider:
let provider = ManagedAppPasswordsProvider()
let password = try await provider.password(withIdentifier: identifier)
The provider is created at the retrieval boundary and the result is never stored. Lookup errors map to typed, secret-free messages (invalidIdentifier, serverError, internalError), which turned out to matter, because those three cases point at three very different problems.
The request itself lives in a WorkflowBroker that takes a specific operation, builds the Authorization header in memory, and returns only the HTTP outcome. Neither the UI nor the logs ever see a value, a length, or a response body.
A note on the development source: use the lower Environment Variables table in the Xcode scheme, not Arguments Passed On Launch. Launch arguments end up on the process command line. GUI apps also do not inherit your shell’s exports, so export POC_API_KEY=... in Terminal does nothing.
Step Two: What Cost Me Two Days
The code was the easy part. Getting macOS to hand the app a password took a series of dead ends, and each one produced the same unhelpful internalError.
Ad-hoc signing. Xcode had forced CODE_SIGN_IDENTITY = "-" on the macOS SDK. The installed app reported TeamIdentifier=not set with adhoc flags, and managedappsd rejected it before looking at any configuration. ManagedApp keys everything off the signed identity, so an ad-hoc build is invisible to it. Removing the override and signing with a real team certificate fixed that layer.
Install behavior. I was targeting an app I had installed by hand. For an existing app, the AppManaged configuration does not take over management unless it sets InstallBehavior:
"InstallBehavior": { "Install": "Required" }
Without it, DDM status showed app.managed.list as not-present and the credential asset sat at valid=unknown, which also explained why my asset server never saw a request. After adding Required, the Device Management pane in System Settings showed the exact composed identifier with Configured passwords: 1.
Order of operations. I applied the declaration while the app was still ad-hoc signed. After fixing the signature, managedappsd recognized the identity but had no record for it, so lookups returned invalidIdentifier. Reapplying the declaration with the properly signed app in place fixed it.
Asset hosting. Last, the credential asset itself: DDM status reported AssetCannotBeDownloaded: Could not connect to the server, with ENETUNREACH in the managedappsd logs, while sudo curl to the same URL returned a clean 200. My notes from that day point at Apple’s documentation, which describes the asset download as a DDM transport with device identity and enrollment trust rules, not a generic anonymous HTTPS GET. I did not fully pin down the root cause for my LAN-hosted fixture, so I will not claim one here; the lesson is that “curl works” does not validate the path the DDM asset client takes.
The most useful debugging tool in all of this was Jamf’s device status-items API for DDM. It shows per-declaration active and valid state with failure reasons, which is far more specific than the generic “1 Error” in the Blueprint UI.
The Declaration
The final shape is two declarations. First, a credential asset of type com.apple.asset.credential.userpassword pointing at a JSON file. Second, an AppManaged configuration that maps a password identifier to that asset. For the broker, the mapping goes on an ExtensionConfigs entry, not on the host app:
"ExtensionConfigs": {
"com.mattparker.ManagedAppSecretsPOC.ManagedAppSecretsSystemBroker (TEAMID)": {
"Passwords": [
{ "Identifier": "poc-system-api-key", "AssetReference": "$PAYLOAD_1" }
]
}
}
The host app’s AppConfig.Passwords entry does not grant access to the embedded broker; each signed component needs its own mapping. $PAYLOAD_1 is Jamf’s placeholder for the first component in the Blueprint, which is why the asset has to come first.
For testing the asset, I wrote a deliberately boring Caddy fixture: one JSON file served over tls internal, Cache-Control: no-store, and request logging discarded so the credential delivery path leaves no access log containing anything useful. The credential in it is disposable test data.
The Broker
The broker is an XPC service embedded in the app. It has one allowlisted operation, a fixed endpoint read from its own Info.plist, and an HTTPS-only check:
guard operation == BrokerOperation.ping.rawValue else {
reply(nil, NSError(domain: "ManagedAppSecretsBroker", code: 1,
userInfo: [NSLocalizedDescriptionKey: "Operation is not allowed."]))
return
}
let password = try await ManagedAppPasswordsProvider()
.password(withIdentifier: "poc-api-key")
request.setValue("Bearer \(password)", forHTTPHeaderField: "Authorization")
The companion CLI is almost nothing. It accepts exactly one argument (ping), connects to a fixed service name, and prints broker HTTP 204 or exits non-zero. There is no argument, flag, or environment variable through which a URL or token could enter or leave.
The Open Question: Root
The user-channel version works for a per-user session. The real target is a Jamf policy, which runs as root, so I added a second, separate broker for the System channel with its own identifier (poc-system-api-key), its own extension mapping, and its own fixed systemPing operation. It deliberately does not share the user-channel secret.
Delivery on the System channel does not prove that an XPC service launched from the root bootstrap context can actually consume a ManagedApp credential. That is the compatibility question this second POC exists to answer, and I have not yet got a recorded result. If it fails, the documented fallback is not to export the secret to a LaunchDaemon, file, or Keychain item; it is to keep the secret-consuming request in a user-scoped extension.
What Is Not Solved
- It is still a bearer string. A ManagedApp secret is scoped to signed code, but once retrieved it is a static token. It is not device-bound. A per-device, narrowly scoped, revocable credential is the minimum; ManagedApp identities or attestation would be better.
- The XPC listener accepts every connection. The POC uses default acceptance. Production needs audit-token code-signature validation against the installed CLI’s designated requirement.
- Operation inputs.
pingtakes nothing. The first real operation (something like an inventory upload) needs path validation, size limits, and per-operation authorization. - Beta software. The framework, the DDM payloads, and vendor support for them are all moving.
The test plan I wrote for the team review has explicit pass criteria and, just as important, explicit stop conditions: do not proceed if the MDM cannot reliably provision and rotate the secret, if a credential appears in any log or response, or if the backend cannot revoke it promptly.
Whether this becomes part of how I run Jamf automation depends on the root-context result. If it holds, scripts stop being the place where secrets live, which is the same instinct behind the pre-commit hooks I wrote about in Shikomi Six Months In, applied one layer further down.