Safari
Safari History
Overview
Safari browsing history is the single most valuable browser artifact for forensic investigations. It records every page visit with precise timestamps, enabling reconstruction of a user's web activity timeline. The history database tracks not only URLs and page titles but also visit counts, redirect chains, and load success indicators -- all of which provide investigative context that goes beyond simple "which sites were visited" analysis.
History data is stored in History.db, a SQLite database using two primary tables: history_items (unique URLs) and history_visits (individual visit events). This separation means a single URL can have many associated visits, each with its own timestamp and metadata.
File Locations
| File | Path | Format |
|---|---|---|
| History database | ~/Library/Safari/History.db | SQLite |
| Write-ahead log | ~/Library/Safari/History.db-wal | SQLite WAL |
| Shared memory | ~/Library/Safari/History.db-shm | SQLite SHM |
All three files should be collected together. The WAL file may contain recent visits that have not yet been checkpointed into the main database.
Database Schema
history_items
Stores one row per unique URL ever visited. The visit_count field is a running total across all time.
| Column | Type | Description |
|---|---|---|
id | INTEGER | Primary key (auto-increment) |
url | TEXT | Full URL (NOT NULL, UNIQUE) |
domain_expansion | TEXT | Domain expansion for autocomplete |
visit_count | INTEGER | Total visits to this URL (NOT NULL, default 0) |
daily_visit_counts | BLOB | Compressed daily visit statistics |
weekly_visit_counts | BLOB | Compressed weekly visit statistics |
autocomplete_triggers | BLOB | Autocomplete trigger data |
should_recompute_derived_visit_counts | INTEGER | Recomputation flag (NOT NULL, default 1) |
visit_count_score | INTEGER | Scoring value for ranking (NOT NULL, default 0) |
history_visits
Stores one row per individual page visit. This is where the forensic timestamps live.
| Column | Type | Description |
|---|---|---|
id | INTEGER | Primary key (auto-increment) |
history_item | INTEGER | Foreign key to history_items.id (NOT NULL) |
visit_time | REAL | Core Data timestamp -- seconds since 2001-01-01 (NOT NULL) |
title | TEXT | Page title at time of visit |
load_successful | INTEGER | 1 if page loaded successfully, 0 if failed (NOT NULL, default 1) |
http_non_get | INTEGER | 1 if HTTP method was not GET (NOT NULL, default 0) |
synthesized | INTEGER | 1 if visit was synthesized (NOT NULL, default 0) |
redirect_source | INTEGER | Visit ID that redirected to this visit (FK to history_visits.id) |
redirect_destination | INTEGER | Visit ID this visit redirected to (FK to history_visits.id) |
origin | INTEGER | Visit origin type (NOT NULL, default 0) |
generation | INTEGER | Sync generation number (NOT NULL, default 0) |
attributes | INTEGER | Visit attributes bitmask (NOT NULL, default 0) |
score | INTEGER | Visit score for ranking (NOT NULL, default 0) |
history_client_version
Metadata table tracking the database schema version.
| Column | Type | Description |
|---|---|---|
key | TEXT | Version key (PRIMARY KEY) |
value | TEXT | Version value |
history_tombstones (Safari 15+, macOS 11.0+)
Tracks deleted history entries for iCloud sync. Present only in Safari 15 and later.
| Column | Type | Description |
|---|---|---|
id | INTEGER | Primary key (auto-increment) |
url | TEXT | URL of the deleted entry (NOT NULL) |
generation | INTEGER | Sync generation (NOT NULL) |
created | REAL | Core Data timestamp of deletion (NOT NULL) |
Key Fields for Analysis
Essential Query
The core forensic query joins both tables to produce a complete visit record:
SELECT
hi.id AS history_item_id,
hi.url,
hi.visit_count,
hv.id AS visit_id,
hv.visit_time,
hv.title,
hv.load_successful,
hv.redirect_source,
hv.redirect_destination
FROM history_items hi
JOIN history_visits hv ON hv.history_item = hi.id
ORDER BY hv.visit_time ASC;
Fields of Particular Interest
url: The full URL including query parameters. Search engine queries are often visible in the URL (e.g.,https://www.google.com/search?q=how+to+cover+tracks).visit_time: Core Data timestamp of the exact moment the page was visited. This is the primary timeline field.title: The HTML page title at the time of visit. May reveal content even if the URL is opaque.load_successful: A value of 0 indicates a failed page load (network error, 404, blocked content). Failed loads still create history entries.redirect_source/redirect_destination: Enable reconstruction of redirect chains. A sequence of redirects from an ad click through tracking URLs to a final destination can be followed.visit_count: High visit counts indicate frequently accessed sites. Comparing visit_count with the actual number ofhistory_visitsrows can reveal whether history has been partially deleted.http_non_get: A value of 1 indicates a POST or other non-GET request, which may suggest form submissions.
Timestamps
Safari history uses Core Data timestamps (seconds since 2001-01-01 00:00:00 UTC).
Conversion formula:
Unix timestamp = visit_time + 978307200
SQL conversion (inline in SQLite):
SELECT
url,
title,
datetime(visit_time + 978307200, 'unixepoch') AS visit_datetime
FROM history_visits hv
JOIN history_items hi ON hi.id = hv.history_item
ORDER BY visit_time DESC
LIMIT 50;
Python conversion:
from datetime import datetime, timezone, timedelta
COREDATA_EPOCH = datetime(2001, 1, 1, tzinfo=timezone.utc)
def coredata_to_utc(ts):
return COREDATA_EPOCH + timedelta(seconds=ts)
# Example: 725760000.0 -> 2024-01-01 00:00:00 UTC
The visit_time field stores fractional seconds, providing sub-second precision. The fractional component is preserved through nanosecond precision in the conversion.
Analysis Notes
- Search query extraction: Google, Bing, DuckDuckGo, and other search engines encode the query in the URL. Extract the
q=parameter for rapid identification of search terms. - Redirect chain reconstruction: Follow
redirect_sourceandredirect_destinationIDs to trace the full path from initial click to final page. This is valuable for phishing and malware delivery analysis. - Deleted history detection: The
history_tombstonestable (Safari 15+) records URLs that were explicitly deleted. The presence of tombstone entries is evidence that the user actively deleted specific history items. - Visit count anomalies: If
visit_counton ahistory_itemsrow is significantly higher than the number of matchinghistory_visitsrows, history entries may have been deleted or the database was pruned by Safari. - WAL recovery: The
History.db-walfile may contain recently committed visits. Always collect the WAL and SHM files alongside the main database. Forensic tools that checkpoint the WAL on a copy can recover visits that have not been flushed to the main database. - Failed loads: Entries with
load_successful = 0indicate attempted but failed visits. This can reveal blocked sites, unreachable servers, or DNS-level filtering. - Timeline correlation: Safari history timestamps correlate well with shell history, FSEvents, Unified Log entries, and filesystem modification times for building a unified activity timeline.
- Private browsing: Safari does not persist history from Private Browsing windows. The absence of expected history in a specific time window may suggest private browsing was used.
Version Differences
| Version | Change |
|---|---|
| Safari 10-14 (macOS 10.12-10.15) | Baseline schema with history_items, history_visits, history_client_version |
| Safari 15+ (macOS 11.0+) | Added history_tombstones table for sync-aware deletion tracking. Added tab_group_uuid column to history_visits |
| Safari 15+ (macOS 12.0+) | Tab Groups feature introduced, tab_group_uuid may link visits to named tab groups |
Older schemas may lack redirect_source and redirect_destination columns. The macfor collector detects this automatically and adjusts its query.
Tool Support
macfor
The browser.safari plugin collects History.db along with its WAL and SHM files as raw artifacts. It also parses the database and emits structured browser_history records in JSONL format with fields including url, title, visit_time (converted to RFC 3339), visit_count, load_successful, redirect_source, redirect_destination, history_item_id, and visit_id.
The parser automatically detects whether redirect columns are present and adapts the query accordingly.
Manual Analysis with SQLite
# Open a copy (never the original) in read-only mode
sqlite3 -readonly History.db
# Recent 20 visits with human-readable timestamps
SELECT datetime(hv.visit_time + 978307200, 'unixepoch') AS dt,
hi.url, hv.title
FROM history_visits hv
JOIN history_items hi ON hi.id = hv.history_item
ORDER BY hv.visit_time DESC LIMIT 20;
# Search queries (Google)
SELECT datetime(hv.visit_time + 978307200, 'unixepoch') AS dt,
hi.url
FROM history_visits hv
JOIN history_items hi ON hi.id = hv.history_item
WHERE hi.url LIKE '%google.com/search%'
ORDER BY hv.visit_time DESC;
# Redirect chains
SELECT hv.id, hv.redirect_source, hv.redirect_destination,
hi.url
FROM history_visits hv
JOIN history_items hi ON hi.id = hv.history_item
WHERE hv.redirect_source IS NOT NULL
OR hv.redirect_destination IS NOT NULL;
# Deleted history (Safari 15+)
SELECT datetime(created + 978307200, 'unixepoch') AS deleted_at, url
FROM history_tombstones
ORDER BY created DESC;
References
- Apple Core Data Date Reference
- SANS FOR518: Mac and iOS Forensic Analysis
- SQLite WAL Mode Documentation