8 min read

macOS process identity needs more than a Team ID

A practical macOS process identity model using Team ID, signing identifier, designated requirements, paths, and strict unsigned-build handling.

macOS process identity needs more than a Team ID

A Team ID is not enough to decide which macOS process may use a secret. It identifies an Apple developer team, not one executable made by that team. If you authorize a Team ID by itself, every suitably signed app, helper, test utility, and future binary from that team can inherit the same access.

A defensible authorization identity combines the signing authority with the signing identifier, records a requirement that survives legitimate updates, and evaluates that identity against the process that is actually asking. The executable path still belongs on the approval card and in the audit record, but it should not carry the authorization decision. Unsigned and ad hoc signed development builds need a separate, visibly weaker path rather than a silent exception.

That distinction matters most at a secret boundary. A code signature can tell you who signed a process and which program name the signer claimed. It cannot tell you that the process deserves a database password, that its current request is sensible, or that another program from the same developer should receive the same grant. Identity is one input to authorization, not a substitute for it.

Team ID identifies a signer, not a program

The Team ID answers a useful but broad question: which Apple developer team issued the signing identity used for this code? For Apple issued developer certificates, the requirement language exposes the Team ID through the leaf certificate's organizational unit field. Tools may also print a TeamIdentifier value in the signature details.

Run this against the exact executable, not merely the outer app bundle:

codesign -dvvv /Applications/Example.app/Contents/MacOS/Example 2>&1

The useful part of the output has this shape:

Executable=/Applications/Example.app/Contents/MacOS/Example
Identifier=dev.example.agent
Authority=Developer ID Application: Example Developer (A1B2C3D4E5)
Authority=Developer ID Certification Authority
Authority=Apple Root CA
TeamIdentifier=A1B2C3D4E5

A valid Team ID gives you signer scope. It excludes binaries signed by other teams, which prevents an unrelated developer from claiming the same signing identifier and passing your check. Apple makes this exact point in its SigningIdentifier documentation: a signing identifier can be claimed by multiple signers, so a secure check for non-Apple code also needs a TeamIdentifier constraint and an appropriate validation category.

The converse is the trap. One team can sign many unrelated products and many components within one product. An app, its privileged helper, a login item, a command line tool, an XPC service, and an internal diagnostic can all share the Team ID. A compromised signing service could produce yet another binary within that signer scope. Team membership does not distinguish any of them.

This is why Team ID == approved team is too wide for secret use. It resembles granting every employee in a company access because their badges came from the same issuer. The issuer matters, but the name on the badge matters too.

There are narrow cases where Team ID alone is intentional. A developer tool might let any component signed by the user's own team access a disposable local fixture. That is a team trust policy, not a process identity, and the approval text should say so. Do not store it under a field named application and later forget how much authority it conveys.

The signing identifier separates programs within a team

The signing identifier supplies the program dimension missing from Team ID. codesign prints it as Identifier. For a bundled app it is commonly the bundle identifier, but Apple explicitly says that equivalence is not required. A signer chooses the value, and command line executables can carry identifiers without being app bundles.

Pairing Team ID and signing identifier produces a much better minimum identity:

team_id = A1B2C3D4E5
signing_identifier = dev.example.agent

The pair means "the program named dev.example.agent as signed by team A1B2C3D4E5." Another team can copy the identifier but cannot satisfy the team constraint. Another program from the approved team should have a different identifier and therefore should not match.

Should is doing work in that last sentence. A team controls its own identifiers and can reuse one. A careless build configuration might assign the main app's identifier to an embedded helper. A malicious or compromised signer could copy the approved value. The pair narrows authority under the assumption that the signer protects both its signing credentials and its identifier namespace.

That is still the normal boundary for update-stable third-party identity on macOS. Code hashes are more specific, but every legitimate release changes them. Certificate fingerprints are also awkward continuity anchors because certificates expire and rotate. Team ID plus signing identifier expresses the continuity most applications want: accept future versions of this program from this signer, without accepting the signer's entire catalog.

Inspect every executable that can connect. Do not infer a helper's identifier from the containing app. Apple TN3127 uses an app and its embedded command line tool to make the same point: they share a Team ID but have different code signing identifiers, and Apple calls that separation best practice. If the helper initiates the request, its identity is the one at the boundary.

Identifiers also need byte-for-byte treatment. Apple's current SigningIdentifier documentation says the comparison performs no Unicode normalization. Store and compare the value returned by Code Signing Services as an opaque string or byte sequence. Lowercasing it, normalizing it, or rebuilding it from CFBundleIdentifier creates a second identity system with different rules.

The designated requirement captures update-stable identity

A designated requirement, usually shortened to DR, is macOS's native expression for deciding whether code seen now is the same code seen before. It combines the signing identifier with constraints on the signing authority. That is closer to the authorization object you need than either field viewed alone.

Apple TN3127 describes a DR as the code's way to state how another party can recognize it again. The technote's practical test is an update: version 1.3 should satisfy the identity recorded for version 1.2 even though its bytes changed. At the same time, a different product should fail. That is exactly the tension a durable secret grant must resolve.

Display the DR with:

codesign -d -r- /Applications/Example.app/Contents/MacOS/Example 2>&1

A Developer ID result has this general shape, with certificate details depending on the signing route:

Executable=/Applications/Example.app/Contents/MacOS/Example
designated => anchor apple generic and identifier "dev.example.agent" and certificate leaf[subject.OU] = "A1B2C3D4E5"

Do not parse that display string into homegrown fields and then attempt to reproduce Apple's evaluator. TN3125 warns that signature structures change and directs developers to codesign or Code Signing Services for validation. Use SecCodeCopyDesignatedRequirement to obtain a requirement and SecCodeCheckValidity or the current process requirement APIs to evaluate code. Let the platform compile and compare its own requirement form.

A subtle point follows: the code's own DR is a claim, not an authorization decision. The signer can supply an explicit DR, and Code Signing Services can synthesize one when none is embedded. Your system still decides which requirement to record and whether its scope fits the grant. Reading a DR and announcing "trusted" confuses identity material with policy.

For ordinary Developer ID software, recording the validated DR at approval time is a sound default. Also store the displayed Team ID and signing identifier as audit fields so a human can understand the decision. On later calls, evaluate the live process against the stored requirement rather than comparing a freshly printed DR string for textual equality. Requirement objects express behavior; equivalent expressions do not need identical formatting.

Distribution changes need deliberate treatment. TN3127 notes that default DRs for Mac App Store and Developer ID variants are not automatically compatible, while Xcode can use custom requirements to bridge intended distribution forms. Do not broaden your verifier on the fly when a release moves channels. Treat the new requirement as an identity change, show it to the user, and require a fresh approval unless you designed and tested an explicit compatibility requirement.

Executable paths provide context, not proof

An executable path tells the operator where macOS found the image. That is excellent context for an approval and weak evidence for continuity. Files move, app translocation can alter locations, users keep multiple versions, and package managers install versioned paths. Conversely, an attacker who can replace a file at an approved writable path can inherit a path-only grant.

The familiar path allowlist fails in both directions. It rejects the same signed program after a harmless move, and it accepts different bytes after a hostile replacement. Adding owner and mode checks reduces some replacement risk but does not turn a pathname into a cryptographic identity.

Keep the path for three jobs:

  • Show the user which installation initiated the request.
  • Record enough context to investigate a surprising call.
  • Apply an optional location restriction after signature identity succeeds.

That last use can be reasonable in managed environments. You may require an approved DR and also require the executable to reside under a root-owned deployment directory. The path then limits where an already identified program may run. It never repairs a missing or invalid signature.

Resolve and record the executable actually associated with the running process. Do not trust a client supplied string such as argv[0], a working directory, a bundle path in a request, or an environment variable. Those are statements made by the requester. Even a canonicalized path can race if you inspect a file and later authorize a process based only on the saved name.

I keep both the original observed path and a resolved path in audit data when the platform supplies them, because symlinks and launch wrappers explain many surprises. Neither value joins the durable identity tuple. If an organization chooses a path restriction, store it in a separate location_constraint field so reviewers can see that it is policy layered on identity.

Evaluate the live caller instead of a pathname

Stop an approved run instantly
The Sessions journal lets you revoke a running agent when its identity or behavior looks wrong.

The authorization check must bind to the process making the connection. Checking a file at installation time, saving its path, and trusting whatever runs there later leaves a time-of-check gap. Checking the current file at that path after a request arrives can still inspect a replacement rather than the image already executing.

macOS Code Signing Services distinguishes static code on disk from dynamic code associated with a running process. Apple documents SecCodeCopyGuestWithAttributes for obtaining a code object for guest code, commonly by PID, and SecCodeCheckValidityWithProcessRequirement for checking a running process against a requirement. The newer lightweight requirement API can make the intended TeamIdentifier and SigningIdentifier constraints explicit. Use the supported API appropriate to your deployment target rather than scraping codesign output in production.

A reliable connection flow looks like this:

  1. Obtain kernel supplied process identity from the IPC boundary, such as the audit token attached to an accepted connection. Do not accept a PID reported in the request body.
  2. Resolve that live process to a dynamic code object and validate its signature with the platform API.
  3. Evaluate the stored requirement, or the stored signer and identifier constraints, against that same code object.
  4. Capture path, PID, process start identity where available, signature facts, and validation result in the decision record.
  5. Bind the approval to the connection or process lifetime, then discard it when that lifetime ends.

A bare PID is not a durable handle because the kernel reuses process IDs. If the verifier reads a PID, waits, and later looks it up again, it may inspect a different process. An audit token carries more process identity than a client supplied integer, and connection-scoped verification reduces the window. The exact API depends on whether your boundary is XPC, a Unix domain socket, or another IPC mechanism, but the rule stays fixed: derive the subject from the operating system's view of the peer.

Validate before displaying identity. Otherwise an approval card may show fields extracted from a malformed or invalid signature as though macOS vouched for them. The card should distinguish valid Developer ID signature from identifier text was present. If validation fails, do not fall back to a path match under the same approval.

Process trees deserve an explicit decision. If a signed agent starts /bin/sh, and the shell connects directly, the peer is the shell. Automatically walking to an ancestor and lending the parent's identity can authorize substituted children or unrelated descendants. If your architecture wants to authorize the launcher, bind a capability to the original authenticated connection and pass it through a controlled channel. Do not rediscover intent by climbing parent PIDs.

This is also why authorization should expire when the approved process exits. A saved identity may support future approvals, but a session grant should not float free and attach itself to any matching process forever. Separate "we recognize this program" from "this run is approved now."

Unsigned and ad hoc builds need a separate policy

Unsigned code has no designated requirement. Ad hoc signed code has a DR tied to that particular code version, so a rebuild changes the identity. Apple TN3127 says macOS cannot reliably track either form across versions. A secret broker should preserve that limitation instead of papering it over with a directory exception.

For production secrets, fail closed when the caller lacks a valid, update-stable signature. This is the cleanest rule and the easiest one to explain. Developers can sign local builds with an Apple Development identity or a private signing identity whose trust and requirements the organization manages. The inconvenience is usually smaller than the ambiguity created by a permanent unsigned exception.

Local development sometimes needs a weaker mode. Make it opt-in, name it development approval, and constrain the blast radius. A reasonable design binds approval to the current process lifetime and exact code hash, shows unsigned or ad hoc prominently, excludes production secret sets, and asks again after every rebuild. The repeated prompt is not a defect. It reflects that the executable no longer has continuity macOS can prove.

Do not use these popular substitutes:

  • A path under the user's home directory, because the same user can replace it.
  • A filename or bundle identifier read from metadata, because unsigned code can claim any value.
  • The parent process's signature, because the child is the peer using the authority.
  • A blanket terminal approval, because terminals launch arbitrary programs.
  • A hash stored as permanent identity, because every legitimate rebuild requires manual migration.

A hash can safely narrow a temporary exception. It says "these exact bytes for this run," not "this is the same program after an update." Keep that semantic difference visible in storage and UI.

Treat a missing Team ID as a state to classify, not a null that bypasses comparison. Apple signed platform code, independently signed code, ad hoc code, and unsigned code do not all fit the same tuple. Define which categories your product accepts, then test each category. An if team != expected check with a permissive empty-value branch has caused enough authorization bugs that it deserves a dedicated unit test.

Store an identity record that explains its own scope

Audit the call behind approval
The Activity journal records individual HTTP and SSH actions after a session is identified.

A durable record should preserve the machine-evaluable requirement, the human-readable signature facts, the code category, and any separately approved location condition. It should also record the scope of the grant. A person reviewing the database six months later must be able to tell whether approval covered one run, future signed versions, or every program from a team.

This example uses placeholder values and a serialized requirement blob represented as base64. The blob should come from Code Signing Services, not from compiling a string that the client supplied:

{
  "schema": 1,
  "code_category": "developer_id",
  "team_id": "A1B2C3D4E5",
  "signing_identifier": "dev.example.agent",
  "designated_requirement": "BASE64_PLATFORM_REQUIREMENT",
  "approval_scope": "matching_identity_per_session",
  "location_constraint": null,
  "observed_path": "/Applications/Example.app/Contents/MacOS/Example"
}

The matching algorithm should be boring. First classify the caller and validate its live signature. For a signed identity, evaluate the stored requirement against that live process. Confirm that the returned Team ID and signing identifier match the audit fields; a mismatch indicates corrupt state or a bug, not a reason to widen access. Then apply any location constraint. Finally consult the grant scope and any per-secret approval requirement.

Pseudocode makes the failure behavior easier to review:

caller = peer_from_kernel(connection)
code = dynamic_code(caller)
result = validate(code)

if result.category not in accepted_categories:
    deny("unsupported code category")

identity = evaluate(stored_requirement, code)
if identity != satisfied:
    deny("process identity changed")

if facts(code) != stored_audit_facts:
    deny("identity record inconsistent")

if location_constraint and not location_allowed(code, location_constraint):
    deny("approved program ran from an unapproved location")

authorize_for(connection_lifetime, requested_secret)

Notice what the algorithm does not do. It does not accept a Team ID alone, compare a path before checking the signature, search parent processes for a friendlier identity, or silently convert an unsigned caller into a development grant. Each failure produces a reason a user and an auditor can understand.

Plan for identity migration as an operation, not an exception. A Team transfer, signing identifier change, distribution channel change, or switch from ad hoc to Developer ID signing can legitimately alter the requirement. Present the old and new facts side by side, require an authorized person to approve the transition, and retain both records in the audit history. Never overwrite the old identity and make past calls appear to have come from the new one.

Test with adversarial fixtures. Sign two executables with the same team and different identifiers; only one should pass. Sign another with the approved identifier under a different team; it should fail. Copy the approved executable to a new path; identity should pass unless a location condition says otherwise. Replace the file after launching and confirm that the verifier assesses the live peer. Rebuild an ad hoc target and confirm that its temporary approval does not survive.

Identity cannot answer whether the action is allowed

End authority with the process
Session approval lasts only until that approved agent run exits, then access closes.

A correct process match only identifies the subject of a request. It does not establish that the subject may use every secret, call every host, or retain access forever. Keep process identity, session approval, and action approval as separate decisions even when one interface presents them together.

This separation prevents a common escalation. A user approves a signed coding agent to read one development token, the system stores the agent identity, and a later implementation starts treating that record as approval for every credential. Nothing about the Team ID, signing identifier, or DR carries the original resource boundary. The identity remained stable while the authorization silently expanded.

Make the authorization tuple explicit: subject, action, resource, conditions, and lifetime. For a secret gateway it may read like this:

subject: requirement R42 satisfied by this live process
action: perform an HTTP request with injected bearer credentials
resource: issue-tracker-development
conditions: vault unlocked and session approved
lifetime: this connection, with per-call approval if the secret requires it

The subject field refers to the process identity record. The other fields come from the requested operation and the controls around it. This structure also handles an agent that uses both HTTP and SSH without pretending that process recognition authorizes both channels automatically.

Entitlements can inform a specialized policy, but they should not become an improvised reputation score. An entitlement is a signed claim that macOS interprets for particular system facilities. Its presence does not mean the program is generally safer, and its absence does not weaken a Team ID plus identifier match. Check a specific entitlement only when your authorization design assigns it a precise meaning.

Notarization and Gatekeeper answer different questions too. Their acceptance can contribute to code category validation and distribution trust, but neither identifies one program within a developer team or grants access to your secret. Running spctl -a -vv -t exec during diagnosis may explain whether Gatekeeper accepts a file. Do not substitute that verdict for your stored requirement check.

Revocation needs the same precision. Revoking a session should stop the current connection without deleting the recognized program identity. Revoking an identity should force matching future processes through approval again. Revoking a resource grant should leave other grants intact. If one boolean named trusted controls all three, the data model cannot express what the user actually decided.

These boundaries make incident review much less speculative. An auditor can determine that a particular signed program ran, that a person approved that run, and that the run received authority for a defined operation. Without all three records, a perfect DR still leaves the most important question unanswered: what did recognizing the process permit?

Approval screens should say what was proved

A human cannot review a raw requirement blob during an interruptive approval. Show a compact claim derived from validated facts: signer or team, signing identifier, signature category, executable path, and whether this approval lasts for the process or for one call. Put the broadest fact first only when the design consciously grants broad authority.

Avoid friendly app names as the primary identity. Display names come from mutable metadata and often collide. They are useful labels after the validated signing facts, much like the path. An approval saying Example Agent wants access hides whether the requester is the released app, a helper, a local rebuild, or an unsigned copy.

Sallyport's first call from a new agent process shows an approval card led by the process's code signing authority, then binds approval to that run until it exits. Its vault gate and per-call approval flag remain separate controls, so recognizing a process never turns identity into unlimited secret access.

The audit event should preserve what the verifier observed and what rule it applied. Record the validated category, Team ID, signing identifier, requirement reference, path, process and session identifiers, decision, denial reason, and grant scope. A later reviewer should not need the current file at the old path to understand the call.

Do not label the result trusted process. The verifier proved a narrower statement: this live process satisfied a particular code requirement and a user or policy granted it a defined action. That wording resists authority creep when new secret types and new agent tools arrive.

A Team ID belongs in that statement, but it cannot carry it. Use Team ID to name the signer, the signing identifier to name the program within that signer, the designated requirement to preserve identity across legitimate releases, and the live process handle to bind the proof to the requester. When any of those pieces is missing, narrow the grant or ask again. A secret boundary should expose uncertainty instead of converting it into permanent access.

FAQ

Can two different macOS apps have the same Team ID?

Yes. Every app and helper signed by the same Apple developer team can share that Team ID. Use the signing identifier or a designated requirement to distinguish programs within the team.

Is a bundle identifier the same as a signing identifier?

Often, but macOS does not require it. The signer chooses the signing identifier, and command line tools can have one without an app bundle, so read it from the validated code signature.

Should I save a designated requirement or Team ID and identifier fields?

Save the machine-evaluable designated requirement and keep Team ID plus signing identifier as readable audit facts. Evaluate the requirement with Code Signing Services rather than parsing and comparing its printed text.

Does a designated requirement prove that a process is safe?

No. It proves that code satisfies an identity expression. You still need a separate decision about which secrets and actions that identity may use, for how long, and with what human approval.

Can an executable path be part of process authorization?

It can be an additional location restriction after signature identity succeeds. A path alone is unsafe because files move and attackers may replace a file at an approved writable location.

How should a service identify the process on an IPC connection?

Derive the peer from kernel supplied connection data such as an audit token, then validate the corresponding live code object. Never trust a PID, path, or identifier supplied inside the client's request.

What happens to authorization when a signed app updates?

A correctly chosen designated requirement should let legitimate updates satisfy the same identity. If the signer, identifier, or distribution route changes outside that requirement, require an explicit identity migration or a new approval.

Can I permanently authorize an unsigned development build?

You can, but a path-based permanent grant has no reliable code continuity. Prefer a process-lifetime development approval bound to the exact build, restricted to nonproduction secrets, and request approval again after rebuilding.

Should a child process inherit its signed parent's identity?

Not automatically. If the child connects, it is the direct peer; walking up the process tree can lend authority to substituted descendants. Pass a scoped capability through a controlled channel if inheritance is part of the design.

Is a code hash a better identity than a Team ID?

A hash identifies exact code bytes, so it works well for a temporary unsigned-build exception. It does not survive legitimate updates, while Team ID plus signing identifier or a designated requirement can express continuity across releases.

Sallyport

Sallyport runs API calls and SSH commands for your AI agent. The keys stay in a local vault on your Mac; you approve each run and every action lands in a sealed journal.

© 2026 Sallyport · Open source under Apache-2.0 · Oleg Sotnikov