Skip to content

Recovering Removed TCC Grants from TCC.db-wal

How deleted or changed TCC permissions can survive in TCC.db-wal and freed pages: SQLite WAL format basics, what tccutil reset leaves behind, and the limits.

Published on 7 min read

TL;DR. TCC.db keeps only the current state of each permission: a new decision replaces the row, and tccutil reset deletes it. But TCC.db runs in SQLite's write-ahead-log mode, and the TCC.db-wal file next to it can still hold earlier copies of the pages that contained those rows: older commits, frames from a previous WAL generation, sometimes uncommitted frames. Freed pages in the main file can hold remnants too. TCC Parser replays the committed WAL to show the current state and puts every earlier or removed row it can recover in the History tab, next to the current value. None of this is guaranteed to survive, so collect the WAL early and treat what you find as supporting evidence.

Why history is missing in the first place

The access table has one row per client, service and target (the primary key is service, client, client_type, indirect_object_identifier). When a user flips a toggle, answers a prompt or an MDM profile changes a grant, that row is overwritten, and last_modified moves forward. When someone runs:

tccutil reset Accessibility
tccutil reset ScreenCapture com.example.agent

the matching rows are deleted (tccutil takes the service name without the kTCCService prefix, and optionally a bundle ID). A reset in System Settings or removal of an app does the same. The main database then shows no trace that the grant existed. That is a problem when an intruder grants a capability, uses it, and removes it before leaving.

SQLite WAL in five minutes

In write-ahead-log mode, SQLite does not write changes into the main file straight away. It appends them to the -wal file and copies them back later, during a checkpoint. The format is documented on sqlite.org; the parts that matter for recovery:

WAL header (32 bytes)

FieldNotes
Magic0x377f0682 or 0x377f0683 (selects checksum byte order)
Format version3007000
Page sizeSame as the database
Checkpoint sequenceIncrements with each checkpoint
Salt-1, Salt-2Random values, changed each time the WAL restarts
Checksum-1, Checksum-2Over the header

Frames, one after another: a 24-byte frame header followed by a full copy of one database page.

Frame header fieldNotes
Page numberWhich page of the database this frame replaces
Database sizeSize in pages after the commit, for commit frames; 0 otherwise
Salt-1, Salt-2Must match the WAL header for the frame to be valid
Checksum-1, Checksum-2Running checksum over the frames so far

A reader replays frames whose salts match the header and whose running checksum is valid, up to the last commit frame, and uses the newest committed copy of each page. That is the current state of the database.

Where the old rows hide

Replaying gives you the present. The past is in the frames that the replay does not use:

SourceWhat it holdsHow likely
Earlier commits in the current WALOlder copies of the same page, from before a later commit in the same WALCommon when several changes happened between checkpoints
Stale-generation framesFrames left from an earlier WAL generation (different salts), not yet overwrittenCommon: after a checkpoint the WAL is reused and restarted with new salts, not zeroed
Uncommitted framesFrames after the last commit frameOccasional; changes that were never committed
Freed pages in the main filePages released after rows or tables shrankOccasional; depends on how SQLite reused the space

TCC.db is small, so the table pages that hold access rows are rewritten often, and each rewrite leaves another copy in the WAL. That is why a removed grant from a few minutes or hours ago often survives, while one from months ago usually does not.

What TCC Parser does with it

When a -wal file sits next to a database (same base name), TCC Parser:

  1. Validates the WAL header and replays committed frames, as SQLite would, to build the current state. The -shm file is not needed.
  2. Decodes the row cells in the other copies of each page: older commits, uncommitted frames and stale-generation frames, plus freed pages in the main file.
  3. Compares each recovered row with the current state by primary key and shows it in the History tab as a removed row (no longer present) or an earlier version (a different auth_value, auth_reason, csreq or last_modified).

The Findings tab raises "grants removed or changed" when a recovered row differs from the current one, for example an Allowed row for a high-impact service that is no longer in the database.

Example: a removed Accessibility grant

In the fictional FIN-MBP-03 sample, the system database's current state has no Accessibility row for the path-based helper /Users/dana.whitlock/Library/Caches/.sync/sync-helper. The WAL tells a different story:

Sourceserviceclientauth_valuelast_modified (UTC)
WAL, earlier commitkTCCServiceAccessibility/Users/dana.whitlock/Library/Caches/.sync/sync-helper2 (allowed)2026-09-14 10:19:05
Current state(no row)

The removal happened at 10:47:12 UTC in the sample story. Deletions do not leave a last_modified of their own in the table, so the time of removal comes from other evidence: the timing of the WAL commit sequence, the unified log (subsystem == "com.apple.TCC"), shell history showing tccutil, or EDR telemetry. On macOS 15.4 and later, Endpoint Security exposes TCC modifications to security tools (ES_EVENT_TYPE_NOTIFY_TCC_MODIFY), so your EDR may have recorded the change directly.

Limits you should state in a report

  • Survival is opportunistic. A checkpoint followed by enough new writes overwrites old frames. A quiet database can keep an old frame for a long time; a busy one loses it quickly.
  • No reliable deletion time. A recovered row gives the state and its last_modified, not when it was deleted.
  • Order within a WAL generation is reliable; across generations it is inferred. Stale frames from an older generation come from before the current one, but gaps are unknown.
  • Collection destroys evidence easily. Opening the database with a tool that writes, or letting SQLite checkpoint on close, rewrites the WAL. Several collectors (UAC's tcc.yaml, Aftermath's TCC module) copy only TCC.db. See TCC.db location and acquisition.
  • Recovered is not the same as current. Report recovered rows as "an earlier state recovered from the WAL", with the source frame, not as live grants.

Other places to look

  • APFS local snapshots and Time Machine: complete older copies of the database. The most reliable way to see a grant that was removed.
  • The expired table: clients whose grants expired, with expired_at.
  • Unified log: tccd decisions under com.apple.TCC, while retention allows.
  • Endpoint Security telemetry (macOS 15.4+): TCC modification events.

Checklist

  1. Collect TCC.db, TCC.db-wal and TCC.db-shm together for the system and each user, before anyone runs tccutil or opens the files.
  2. Hash, then work on copies of the copies.
  3. Load the folder in TCC Parser and open History.
  4. For each removed or changed high-impact row, note the source (WAL commit, stale generation, uncommitted, freed page).
  5. Look for the matching change in the unified log, shell history and EDR data.
  6. Check snapshots and backups for a full earlier copy.

Glossary: SQLite WAL, tccutil.

Related articles

How the TCC.db access table changed from Mojave to Big Sur and Sonoma: allowed, auth_value, auth_reason, pid and last_reminded, plus the other tables.
Step by step: load macOS TCC.db files and their WAL into a free in-browser parser, read the findings, review permissions and history, and export.
How to read the csreq and indirect_object_code_identity blobs in TCC.db: compiled code requirements, anchors, identifiers and cdhash-only ad-hoc clients.