browserforensics
Back to all articles

Chrome History database forensics

2026-06-21 · 5 min

The History database is the single richest timeline source in a Chromium profile. It is not just a list of URLs — it records every individual visit with microsecond timing, how the user arrived at each page, the referrer chain, and the queries typed into the omnibox. Read the schema properly and you reconstruct intent, not just presence.

Where it lives

History is a SQLite database at …\User Data\<profile>\History. Edge uses the same filename and the same schema. There is no extension. The profile is Default, Profile 1, Profile 2, etc. — enumerate them all.

Always grab the -wal sidecar with it; recent visits live there until a checkpoint merges them. See recovering recent history from the WAL and Chrome history file locations.

The two core tables

The urls table is one row per distinct URL; the visits table is one row per visit event. Join visits.url to urls.id.

urls

ColumnMeaning
idPrimary key, referenced by visits.url
urlThe full URL
titleLast page title seen
visit_countTotal visits to this URL
typed_countTimes the URL was typed (not clicked)
last_visit_timeMost recent visit (WebKit epoch)
hidden1 = excluded from autocomplete UI

A high typed_count is a strong signal of deliberate navigation — the user knew the address. hidden rows still happened; they are just suppressed in the address bar, not deleted.

visits

ColumnMeaning
idVisit primary key
urlFK to urls.id
visit_timeWhen the visit occurred (WebKit epoch)
visit_durationDwell time in microseconds
transitionPacked transition type + qualifier bits
from_visitvisits.id of the referring visit
opener_visitVisit that opened this one (e.g. new tab)

from_visit is the referrer chain. Walk it backwards to reconstruct how a user got from a search results page to a specific destination, click by click. opener_visit links a page to the tab that spawned it.

Decoding the transition field

transition is an integer that packs two things. The core type is the low byte — mask with 0xff. The upper bits are qualifier flags.

Core type (& 0xff)ValueMeaning
LINK0Clicked a link
TYPED1Typed in the address bar
AUTO_BOOKMARK2Opened from a bookmark / UI suggestion
AUTO_SUBFRAME3Subframe loaded automatically
MANUAL_SUBFRAME4Subframe loaded by user action
GENERATED5Omnibox search / generated URL
FORM_SUBMIT7Submitted a form
RELOAD8Reloaded the page
KEYWORD9Keyword/custom-search-engine navigation

Common qualifier bits set in the upper bytes:

QualifierMaskMeaning
CHAIN_START0x10000000First visit in a redirect chain
CHAIN_END0x20000000Last visit in a redirect chain
CLIENT_REDIRECT0x40000000JS / meta-refresh redirect
SERVER_REDIRECT0x80000000HTTP 3xx redirect

So TYPED with no qualifiers is a clean manual navigation. A LINK with SERVER_REDIRECT and no CHAIN_START is an intermediate hop, not a real destination — do not present it as a page the user chose to visit. The AUTO_SUBFRAME rows are almost always ads and trackers; filter them out of a timeline unless framing matters.

Omnibox searches

keyword_search_terms records queries typed into the address bar that resolved to a search. It has term (the literal query string) and a url_id joining to urls.id. This recovers the search text — "how to delete browser history", a name, an address — independent of the search engine's own logs. It is one of the highest-value tables in the database.

Supporting tables

  • segments / segment_usage — aggregate per-URL visit stats that power the "most visited" tiles. Useful for ranking habitual sites.
  • visit_source — flags whether a visit was synced from another device or local. Critical when arguing a visit happened on this machine versus arriving via Chrome Sync.
  • downloads / downloads_url_chains — download records live in the same database. Covered separately in Chrome download history forensics.

Timestamps

Every time column (last_visit_time, visit_time) is the WebKit/Chrome epoch: microseconds since 1601-01-01 UTC. visit_duration is also in microseconds, but it is an elapsed interval, not an epoch value. Convert correctly or your timeline drifts by centuries — see browser timestamp formats.

Pitfalls

  • A populated -wal can hold the entire current browsing session. Open the DB and WAL together or you report a stale last_visit_time.
  • visit_count and the actual count of visits rows can disagree after a partial history clear — Chrome may delete visit rows but leave the urls aggregate behind. Cross-check both.
  • Redirect chains inflate the apparent number of "visited" sites. Always resolve CHAIN_START/CHAIN_END before counting destinations.
  • A deleted URL removes the urls row and its visits, but pages in the freelist and WAL frames often still hold them.

The tool parses urls, visits (with transitions decoded into core type plus qualifiers), keyword_search_terms and downloads, applies the WAL automatically, and renders timestamps in the WebKit epoch — entirely in your browser, with nothing uploaded.

Further reading