# Device-bound cookies (DBSC)

> How agentcookie detects DBSC-suspect cookies, the default warn-and-ship behavior, and the `--skip-dbsc-suspect` / `AGENTCOOKIE_SKIP_DBSC_SUSPECT` drop path.

- Repository: mvanhorn/agentcookie
- GitHub: https://github.com/mvanhorn/agentcookie
- Human docs: https://grok-wiki.com/public/docs/mvanhorn-agentcookie-137da38edfae
- Complete Markdown: https://grok-wiki.com/public/docs/mvanhorn-agentcookie-137da38edfae/llms-full.txt

## Source Files

- `internal/chrome/dbsc.go`
- `internal/chrome/dbsc_test.go`
- `internal/cli/source.go`
- `docs/threat-model.md`

---

---
title: "Device-bound cookies (DBSC)"
description: "How agentcookie detects DBSC-suspect cookies, the default warn-and-ship behavior, and the `--skip-dbsc-suspect` / `AGENTCOOKIE_SKIP_DBSC_SUSPECT` drop path."
---

The source watcher runs every Chrome cookie it reads through `chrome.ClassifyCookies` (`internal/chrome/dbsc.go`) before sealing the sync envelope. The classifier is a conservative, read-only heuristic: it never reads a site's actual DBSC `credentials` list (which is not exposed in Chrome's Cookies SQLite), it only fingerprints cookies that look device-bound by host or remaining TTL. The default verdict is non-destructive — suspect cookies still ship, paired with a warning that surfaces in `agentcookie doctor` and `source-state.json`. Setting `--skip-dbsc-suspect` on `agentcookie source`, or the environment variable `AGENTCOOKIE_SKIP_DBSC_SUSPECT=1`, switches the verdict to a hard drop on the source so the cookies never reach the sink.

## What DBSC changes

Chrome's Device Bound Session Credentials bind a per-session private key to the source Mac's Secure Enclave and issue short-lived cookies that the browser silently refreshes by signing a server challenge. A cookie copied to the sink works only until that short-lived cookie expires, because the sink cannot sign the refresh. agentcookie cannot read a site's DBSC `credentials` list from the Cookies SQLite, so the detector keys on the observable fingerprint instead: a secure cookie that is either on a known DBSC host or carries an unusually short remaining TTL.

DBSC is opt-in per site. As of May 2026 the one broad adopter is Google's own account and Workspace cookies. Non-DBSC sites and the entire secrets bus (`~/.agentcookie/secrets/<cli>/secrets.env`) are unaffected by this classifier and replicate normally.

## Detection heuristic

`ClassifyDBSC(cookie, now, skip)` is a pure function with no side effects on the cookie. It runs in this order:

1. If `IsSecure == 0`, return `DBSCShip`. DBSC binds session/auth cookies, which are always `Secure`; this also keeps ordinary short-lived preference and analytics cookies clean.
2. Lowercase the cookie's `HostKey` and trim a leading dot. If the host equals or is a subdomain of any entry in `dbscKnownHosts`, the cookie is suspect regardless of TTL. The current list is `google.com` only.
3. Else if the cookie is persistent (`HasExpires == 1`, `ExpiresUTC != 0`) and its expiry is in the future but within `dbscShortTTL` (15 minutes) of `now`, the cookie is suspect.
4. Otherwise return `DBSCShip` with an empty reason.

When the cookie is suspect, the verdict is `DBSCSkip` if `skip == true` and `DBSCShipWarn` otherwise. The reason string is built from the cookie's name, host, and the trigger.

<ParamField body="dbscShortTTL" type="time.Duration" default="15 * time.Minute">
Remaining-lifetime ceiling below which a secure, persistent cookie is treated as DBSC-suspect. DBSC short-lived cookies are typically refreshed every 5–10 minutes; 15 minutes is a deliberately conservative ceiling that catches them without flagging ordinary cookies.
</ParamField>

<ParamField body="dbscKnownHosts" type="[]string" default='["google.com"]'>
Registrable-domain suffixes known to use DBSC today (Google account / Workspace auth). Match is suffix-based on the lower-cased `HostKey` after the leading dot is trimmed.
</ParamField>

<ParamField body="chromeEpochOffsetMicros" type="int64" default="11644473600 * 1_000_000">
Microsecond gap between Chrome's 1601-01-01 epoch (used by `expires_utc`) and the Unix 1970-01-01 epoch. `chromeTimeToUnix` subtracts this offset to convert Chrome `expires_utc` values to a `time.Time` for the TTL check.
</ParamField>

### Why these specific signals

| Signal | Why it is reliable enough | Why it is not perfect |
|---|---|---|
| `IsSecure == 0` short-circuits to `DBSCShip` | DBSC credentials are always `Secure`; non-secure cookies cannot be DBSC. | None — this is a structural property of DBSC. |
| Known host suffix `google.com` | Only broad DBSC adopter as of May 2026. | A new adopter is not flagged until the host is added; agentcookie ships cookies on unknown hosts unless TTL also trips. |
| Remaining TTL ≤ 15 minutes | DBSC refresh cycles are typically 5–10 minutes. | A site that issues short non-DBSC cookies will trip the heuristic; default warn mode never breaks it because the cookie still ships. |
| Session cookies (`HasExpires == 0`) excluded | DBSC short-lived cookies are persistent with an explicit `expires_utc`. | Session auth cookies on a DBSC host still match via the host rule. |
| Already-expired cookies excluded | The normal pipeline handles expiry; no need to re-flag. | None. |

## Decisions

`DBSCDecision` is the routing verdict for one cookie under the heuristic.

| Constant | `String()` | Effect on the push |
|---|---|---|
| `DBSCShip` | `ship` | Cookie is not DBSC-suspect; included in the envelope. |
| `DBSCShipWarn` | `ship+warn` | Cookie looks DBSC-bound; still included in the envelope and a reason is appended to `DBSCResult.Warned`. |
| `DBSCSkip` | `skip` | Cookie looks DBSC-bound and skip-mode is on; dropped from the envelope and a reason is appended to `DBSCResult.Skipped`. |

`ClassifyCookies(cookies, now, skip)` runs `ClassifyDBSC` over a batch and returns a `DBSCResult` whose `Shipped` slice is the cookies that should be pushed. In warn mode every cookie is shipped; in skip mode the suspects are dropped.

```go
// internal/chrome/dbsc.go
type DBSCResult struct {
    Shipped []Cookie
    Warned  []string // one reason per suspect that was still shipped
    Skipped []string // one reason per suspect that was dropped
}
```

## Push integration

`pushOnce` in `internal/cli/source.go` calls `chrome.ClassifyCookies(all, time.Now().UTC(), skipDBSC)` immediately after the blocklist filter and before the envelope is sealed. The classified `Shipped` slice replaces `all`, and per-push counts plus up to three sample reasons are written into `SourceState` for `doctor` and `status` to read.

```text
chrome.ReadCookiesForHost  -->  blocklist filter  -->  ClassifyCookies
                                                            |
                              warn mode: Shipped == all       skip mode: suspects dropped
                                                            v
                                            SyncEnvelope.Cookies = Shipped
```

The skip toggle is resolved once per process:

```go
// internal/cli/source.go
skipDBSC := sourceSkipDBSC || os.Getenv("AGENTCOOKIE_SKIP_DBSC_SUSPECT") == "1"
```

Per-push, the source writer records the tally on `SourceState`:

```go
srcState.LastDBSCWarned  = dbsc.warned
srcState.LastDBSCSkipped = dbsc.skipped
srcState.LastDBSCSample  = dbsc.sample // up to 3 reasons, warns first
```

## Operator controls

<ParamField body="--skip-dbsc-suspect" type="bool" default="false">
Drop cookies that look device-bound (DBSC) instead of shipping them with a warning. Switches every `DBSCShipWarn` verdict to `DBSCSkip`. Equivalent to `AGENTCOOKIE_SKIP_DBSC_SUSPECT=1`.
</ParamField>

<ParamField body="AGENTCOOKIE_SKIP_DBSC_SUSPECT" type="env" default="unset">
When set to `1`, has the same effect as `--skip-dbsc-suspect`. Lets a LaunchAgent opt into skip mode without editing the plist's argv. Any value other than `1` (including unset) keeps default warn-and-ship behavior.
</ParamField>

<ParamField body="--verbose" type="bool" default="false">
Print a one-line summary plus up to three per-cookie reasons on stderr when any suspects are flagged. Without `--verbose`, the per-cookie detail is suppressed in `--watch` mode to avoid flooding the LaunchAgent log for any user with a persistent Google cookie; the per-push human summary still carries `(N DBSC-suspect: W warned, S skipped)` from `dbscNote`.
</ParamField>

<Tabs>
<Tab title="Warn and ship (default)">

```bash
agentcookie source --once
# agentcookie source: posted 87 cookies, sink replied: ok (2 DBSC-suspect: 2 warned, 0 skipped)
```

</Tab>
<Tab title="--skip-dbsc-suspect">

```bash
agentcookie source --once --skip-dbsc-suspect
# agentcookie source: posted 85 cookies, sink replied: ok (2 DBSC-suspect: 0 warned, 2 skipped)
```

</Tab>
<Tab title="LaunchAgent env var">

```xml
<key>EnvironmentVariables</key>
<dict>
  <key>AGENTCOOKIE_SKIP_DBSC_SUSPECT</key>
  <string>1</string>
</dict>
```

</Tab>
</Tabs>

## Observable output

### Per-push JSON

`emit` writes one record per push when `--json` is set. The DBSC counters are stable keys on every push:

```json
{
  "cookies_read": 95,
  "cookies_blocked": 6,
  "cookies_passing": 87,
  "cookies_dbsc_warned": 2,
  "cookies_dbsc_skipped": 0,
  "secrets_clis": 4,
  "dry_run": false,
  "sink_url": "http://...:8443/sync",
  "posted": true,
  "sink_status": 200,
  "sink_response": "ok"
}
```

### `agentcookie doctor`

`checkDBSCFrom(st)` in `internal/cli/doctor.go` reports the DBSC posture of the last push:

| Condition | Severity | Detail |
|---|---|---|
| No `SourceState` yet, or `LastDBSCWarned == 0 && LastDBSCSkipped == 0` | `OK` | `no device-bound (DBSC) cookies flagged in the last push` |
| Either counter > 0 | `WARN` | `last push flagged N shipped-with-warning, M skipped DBSC-suspect cookie(s): <first sample reason>` plus remediation pointing at signing the sink's Chrome into the same Google account |

### Verbose stderr (warn mode example)

```
agentcookie source: 2 cookie(s) look device-bound (DBSC); shipping with a warning. These likely will not work on the sink. See README: DBSC.
  - cookie "SID" on known DBSC host ".google.com" is device-bound to the source Mac and will not survive on the sink; sign the sink's Chrome into the same account instead (see README: DBSC)
  - secure cookie "auth" on ".example.com" expires within 15m0s; if this site uses DBSC it will not refresh on the sink
```

## Behavior matrix

`internal/chrome/dbsc_test.go` pins the contract. The cases below match named test entries:

| Cookie (vs `now = 2026-05-29 12:00 UTC`) | `skip` | Verdict | Reason substring |
|---|---|---|---|
| `.instacart.com` `session`, secure, expires `+720h` | false | `DBSCShip` | (empty) |
| `.google.com` `SID`, secure, expires `+720h` | false | `DBSCShipWarn` | `DBSC` |
| `.example.com` `auth`, secure, expires `+7m` | false | `DBSCShipWarn` | `expires within` |
| `.example.com` `prefs`, **not secure**, expires `+7m` | false | `DBSCShip` | (empty) |
| `.example.com` `auth`, secure, expires `-5m` (already expired) | false | `DBSCShip` | (empty) |
| `.example.com` `auth`, secure, **session cookie** (`HasExpires=0`) | false | `DBSCShip` | (empty) |
| `.example.com` `auth`, secure, expires `+7m` | **true** | `DBSCSkip` | `expires within` |
| `.instacart.com` `session`, secure, expires `+720h` | **true** | `DBSCShip` | (empty) |
| `ACCOUNTS.GOOGLE.COM` `__Secure-1PSID`, secure, expires `+720h` | false | `DBSCShipWarn` | `DBSC` (suffix match is case-insensitive) |

Batch-mode invariants from `TestClassifyCookies`:

- Warn mode ships all input cookies and records one warning per suspect; `Skipped` is always empty.
- Skip mode ships only clean cookies; `Skipped` carries one reason per dropped suspect; warnings are not also recorded for skipped cookies.

## Scope and limits

<Warning>
The heuristic is a proxy signal, not a guarantee.
</Warning>

- agentcookie does not read a site's DBSC `credentials` list. A site that has adopted DBSC but is not in `dbscKnownHosts` and issues long-lived suspect cookies will pass as `DBSCShip` until the TTL falls under 15 minutes or the host is added.
- A site that issues short-TTL non-DBSC cookies will be flagged. The default verdict (warn) leaves the cookie in the envelope, so a false positive never breaks a working non-DBSC site; only `--skip-dbsc-suspect` turns it into a drop.
- DBSC binds cookies, not the secrets bus. Bearer tokens, API keys, and OAuth refresh tokens that land in `~/.agentcookie/secrets/<cli>/secrets.env` are out of scope for this classifier.
- For Google sessions specifically, copying cookies was never the right tool. Sign the sink's Chrome into the same Google account once; it establishes its own device-bound session locally, and the agent on the sink reads that session.
- `dbscKnownHosts` lives in source and is not configurable at runtime; extending it currently requires a code change in `internal/chrome/dbsc.go`.

## Related pages

<CardGroup>
  <Card title="Cookie delivery surfaces" href="/cookie-delivery-surfaces">The three sink-side delivery paths the warn-and-ship verdict feeds into.</Card>
  <Card title="doctor and adapter verification" href="/doctor-health-checks">The `DBSC` check category and the `DoctorReport` JSON envelope.</Card>
  <Card title="CLI reference" href="/cli-reference">All `agentcookie source` flags, including `--skip-dbsc-suspect`.</Card>
  <Card title="Troubleshooting" href="/troubleshooting">DBSC-suspect drops, stale sessions on Google hosts, and recovery guidance.</Card>
</CardGroup>
