Ad-hoc code signing silently orphans your macOS TCC permissions
If you build your own macOS daemons or CLI tools - anything that needs Accessibility, Full Disk Access, or "access data from other apps" - you have probably hit this: every time you rebuild and redeploy the binary, macOS prompts for the permission again. Click, rebuild, prompt, click. At one or two builds a week you tolerate it. At CI speed (dozens of deploys a day) it becomes an outage machine: the new binary sits blocked inside an open() call waiting for a dialog nobody is looking at.
The root cause is how TCC (Transparency, Consent, and Control) decides whether the thing asking for access is "the same program" you approved last time.
How TCC identifies a binary
When you grant a permission, TCC stores a row in TCC.db containing a code signing requirement (csreq) - a compiled predicate that future binaries must satisfy to inherit the grant. What goes into that predicate depends entirely on how the binary was signed:
- Signed with a certificate and an explicit identifier, the designated requirement looks like:
identifier "com.me.mytool" and certificate leaf[subject.CN] = "My Dev Cert". Any future build signed the same way satisfies it. The grant survives rebuilds. - Ad-hoc signed (
codesign -s -), there is no certificate to pin, so the requirement degrades to the only stable thing available:cdhash H"…"- the hash of the code itself. That hash changes on every build.
So an ad-hoc re-sign doesn't delete your grant. We verified this directly: TCC.db is SIP-protected and its mtime doesn't change when you re-sign. The grant row is still there - it just no longer matches the binary asking. It's orphaned. macOS treats your rebuilt tool as a stranger and prompts again.
The satisfying corollary: re-sign the same binary with the original stable identity and the old grant matches again, with no new prompt. The permission was never gone; the binary just stopped answering to its name.
The recipe: a stable self-signed identity
You don't need a paid Apple Developer certificate for tools that only run on your own machines. A self-signed code-signing certificate created in Keychain Access works, as long as you pin everything explicitly:
codesign -f -s "My Dev Cert" \
--identifier com.me.mytool \
--requirements '=designated => identifier "com.me.mytool" and certificate leaf[subject.CN] = "My Dev Cert"' \
/path/to/mytool
Three things matter:
- Fixed
--identifier. Without it, codesign derives the identifier from the filename - rename the binary and you've changed identity. - Explicit designated requirement pinned to the certificate's CN. This is the predicate TCC stores. Make it say exactly what "same program" means to you.
- Sign every build the same way, including CI builds. One ad-hoc build in the pipeline orphans the grant until the next stable re-sign.
The gotchas that will still bite you
You cannot sign over SSH (by default). codesign needs the login keychain unlocked and the signing key's ACL satisfied; an SSH session has neither, and the failure is the maximally unhelpful errSecInternalComponent. Options: unlock the keychain explicitly in the session, or - more robust for CI - run a small watcher in the user's GUI login session that re-signs freshly deployed binaries. (A GUI-session LaunchAgent has the unlocked keychain and can even receive TCC prompts; an SSH session can do neither.)
Sign the file that isn't running. Signing a binary that's currently executing fails with the same opaque error. Deploy to mytool.new, sign, then atomically mv into place.
Check for grants before you ever ad-hoc sign. Ad-hoc is genuinely fine for pure CLI tools with no TCC grants. The rule of thumb we settled on: before ad-hoc-signing anything, ask TCC:
sqlite3 "$HOME/Library/Application Support/com.apple.TCC/TCC.db" \
"SELECT service, client FROM access WHERE client LIKE '%mytool%' AND auth_value = 2"
Any row back means the binary holds a live grant - stable-sign it, always. (Reading TCC.db itself requires Full Disk Access, which your terminal may already have.)
tccutil only revokes. There is no supported CLI to grant a TCC permission. The only zero-click pre-grant path is MDM with a PPPC profile - fine for managed fleets, unavailable on a personal machine. Everything else goes through the user clicking a dialog, which is exactly why you want that click to happen once, ever.
Takeaways
- TCC grants are keyed to a signing requirement, not a file path.
- Ad-hoc signing pins the requirement to a per-build hash → every rebuild orphans (not deletes) the grant.
- A self-signed cert + fixed identifier + CN-pinned designated requirement makes grants survive unlimited rebuilds.
- Never sign over SSH, never sign a running file, and never let one ad-hoc build sneak into a pipeline that ships TCC-privileged binaries.
Related posts
launchd traps: WatchPaths ignores `touch`, and your keychain is locked over SSH
The failures below all come from running a real automation stack on launchd for months.
NSWindow released me twice: a crash from 1998 and the window-level ladder nobody documents
Two AppKit lessons from building a menu-bar assistant daemon that puts overlays, toasts, and cards on screen all day.
A 26,000-line Swift app with no Xcode: what bare `swiftc` taught us
Our menu-bar assistant daemon is a Swift/AppKit app that grew to ~26K lines - and it has never opened Xcode.