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

FilePathFormat
History database~/Library/Safari/History.dbSQLite
Write-ahead log~/Library/Safari/History.db-walSQLite WAL
Shared memory~/Library/Safari/History.db-shmSQLite 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.

ColumnTypeDescription
idINTEGERPrimary key (auto-increment)
urlTEXTFull URL (NOT NULL, UNIQUE)
domain_expansionTEXTDomain expansion for autocomplete
visit_countINTEGERTotal visits to this URL (NOT NULL, default 0)
daily_visit_countsBLOBCompressed daily visit statistics
weekly_visit_countsBLOBCompressed weekly visit statistics
autocomplete_triggersBLOBAutocomplete trigger data
should_recompute_derived_visit_countsINTEGERRecomputation flag (NOT NULL, default 1)
visit_count_scoreINTEGERScoring value for ranking (NOT NULL, default 0)

history_visits

Stores one row per individual page visit. This is where the forensic timestamps live.

ColumnTypeDescription
idINTEGERPrimary key (auto-increment)
history_itemINTEGERForeign key to history_items.id (NOT NULL)
visit_timeREALCore Data timestamp -- seconds since 2001-01-01 (NOT NULL)
titleTEXTPage title at time of visit
load_successfulINTEGER1 if page loaded successfully, 0 if failed (NOT NULL, default 1)
http_non_getINTEGER1 if HTTP method was not GET (NOT NULL, default 0)
synthesizedINTEGER1 if visit was synthesized (NOT NULL, default 0)
redirect_sourceINTEGERVisit ID that redirected to this visit (FK to history_visits.id)
redirect_destinationINTEGERVisit ID this visit redirected to (FK to history_visits.id)
originINTEGERVisit origin type (NOT NULL, default 0)
generationINTEGERSync generation number (NOT NULL, default 0)
attributesINTEGERVisit attributes bitmask (NOT NULL, default 0)
scoreINTEGERVisit score for ranking (NOT NULL, default 0)

history_client_version

Metadata table tracking the database schema version.

ColumnTypeDescription
keyTEXTVersion key (PRIMARY KEY)
valueTEXTVersion value

history_tombstones (Safari 15+, macOS 11.0+)

Tracks deleted history entries for iCloud sync. Present only in Safari 15 and later.

ColumnTypeDescription
idINTEGERPrimary key (auto-increment)
urlTEXTURL of the deleted entry (NOT NULL)
generationINTEGERSync generation (NOT NULL)
createdREALCore 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 of history_visits rows 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_source and redirect_destination IDs 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_tombstones table (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_count on a history_items row is significantly higher than the number of matching history_visits rows, history entries may have been deleted or the database was pruned by Safari.
  • WAL recovery: The History.db-wal file 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 = 0 indicate 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

VersionChange
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

Previous
Safari Overview