# Auth cookie lifecycle — design notes and field findings **Last Updated:** 2026-07-04 > **Status:** current design notes for the auth refresh stack in `main`. > The numbered recovery ladder below is the canonical taxonomy, shared with > [docs/troubleshooting.md](troubleshooting.md#authentication-errors): > > - **L1** — per-call `RotateCookies` POST (default ON) > - **L2** — periodic background keepalive (`keepalive=N` client kwarg) > - **L3** — headless re-auth from a persisted browser profile / loopback CDP > - **L4** — **master-token re-mint** (`[headless]` extra; no browser, fully automatic) > - **L5** — `NOTEBOOKLM_REFRESH_CMD` external recovery script > - **L6** — manual `notebooklm login` > - **L7** — OS-scheduled `notebooklm auth refresh` (cron / launchd / systemd) > > For long-lived, unattended, headless, server, or CI use, **L4 master-token > re-mint is the recommended path** — see [Recommended setup](#recommended-setup). > Deep field notes, ruled-out experiments, and the internal-persistence hazard > log have been moved to the [Appendix](#appendix-field-notes--historical-findings) > so the body stays focused on durable mechanics. ## TL;DR NotebookLM has no public OAuth surface. The library authenticates by carrying Google session cookies (`SID`, `__Secure-1PSID`, `__Secure-1PSIDTS`, `OSID`, and friends) extracted from a real browser sign-in. Two clocks govern their validity: - **`__Secure-1PSIDTS` has a *recommended* rotation cadence of ~600 s** (self-reported by Google as `["identity.hfcr",600]` on the `RotateCookies` response). This is a *hint*, not a hard rejection TTL: the prior value keeps authenticating far longer — commonly hours to days on a stable IP / non-Workspace account. Worst-case profiles (datacenter egress, cross-IP, Workspace policy, incomplete extraction) can collapse that to hours or less. - **`SID` and `__Secure-1PSID`** have very long server-side lifetimes (months to years) and effectively don't expire under normal usage. - **Cookie set completeness matters more than freshness.** Google rejects cookie sets missing `__Secure-1PSIDTS` together with any one other cookie, even though removing `__Secure-1PSIDTS` alone is recoverable — see [§3.3](#33-empirical-cookie-requirements). A long-lived client must therefore drive `*PSIDTS` rotation itself. The cleanest mechanism is a direct `POST` to `https://accounts.google.com/RotateCookies` — Google's dedicated unsigned rotation endpoint, the **L1** primitive at the bottom of a tiered recovery design that escalates as failure modes get harder. The recovery ladder runs cheapest-to-heaviest — **L1** per-call `RotateCookies` POST, **L2** background keepalive, **L3** headless re-auth / loopback CDP, **L4** master-token re-mint, **L5** `NOTEBOOKLM_REFRESH_CMD`, **L6** manual `notebooklm login`, **L7** scheduled `notebooklm auth refresh` (the same taxonomy as [troubleshooting.md](troubleshooting.md#authentication-errors); per-layer detail in [§4](#4--the-recovery-ladder)). L1/L2 keep a live session fresh but can't revive a dead one; L3 needs the profile's browser session still alive; **L4 is the only fully-automatic layer that revives a fully-expired session with no browser.** **L4 (master-token re-mint) is the standout for headless/unattended use.** Unlike L3 it needs no browser at refresh time, and unlike L5/L6 it is fully automatic. One durable master token — one human sign-in, then good for months — re-mints web cookies on demand and self-heals an expired session in-process, coalesced through the `AuthRefreshCoordinator` single-flight. See [§4.4](#44-l4--master-token-re-mint) and [ADR-0023](adr/0023-master-token-headless-auth.md). `NOTEBOOKLM_REFRESH_CMD` ([#336](https://github.com/teng-lin/notebooklm-py/pull/336)) is a complementary, reactive hook: it runs a user-supplied recovery command on auth-expiry signals, then retries the token fetch once. It is **orthogonal to L1–L4** — those proactively keep the session fresh or re-mint it in-process, while `NOTEBOOKLM_REFRESH_CMD` is the "we lost the session anyway, run my recovery script" lever. See [§6.2](#62-notebooklm_refresh_cmdcommand-line-l5). L1 works today on every account type tested. Long-running Python workers should add L2; unattended/headless/server/CI deployments should adopt L4; idle profiles between processes can add L7. If Google extends DBSC enforcement to non-Chrome cookie paths, L3's CDP arm becomes the primary browser-backed recovery path. --- ## Available auth methods There are five ways to give the library credentials. Pick by deployment shape; they compose (e.g. `--master-token` for the durable credential plus `NOTEBOOKLM_REFRESH_CMD` as a belt-and-suspenders reactive hook). | Method | Command / env | Best for | Survives cookie expiry unattended? | Setup cost | |---|---|---|:-:|---| | **(a) Interactive login** | `notebooklm login` (Playwright Google sign-in into a private Chromium profile) | Desktop / interactive use | No — re-login when prompted | Low (one browser sign-in) | | **(b) Browser-cookie reuse** | `notebooklm login --browser-cookies ` (rookiepy extraction from an existing profile) | Reusing a browser you already sign into | Only while the source browser session stays alive; pairs with L7 cron | Low (no interaction) | | **(c) Master token** ⭐ | `notebooklm login --master-token` (`[headless]` extra; durable token, headless L4 re-mint) | **Servers / CI / unattended / headless** | **Yes** — re-mints automatically, no browser | Medium (one bootstrap sign-in, ship `master_token.json`) | | **(d) Inline auth JSON** | `NOTEBOOKLM_AUTH_JSON=` | CI / ephemeral containers with no on-disk profile | No — env-var auth has no writeable file, so L3/L4 decline | Low (paste a secret) | | **(e) External refresh hook** | `NOTEBOOKLM_REFRESH_CMD=` | Custom recovery (CookieCloud pull, browser re-extract) layered on any of the above | Depends on the script | Medium (write + secure a script) | Notes: **(c) is the recommended default for long-lived headless use** — the only method that both survives full cookie expiry *and* needs no browser at refresh time. **(d) `NOTEBOOKLM_AUTH_JSON`** carries a full `storage_state.json` payload inline (credential-equivalent) with no backing file, so the file-backed recovery layers (L3/L4) decline — good for short-lived CI jobs, but pair with (c) or (e) for anything long-running. **(e) `NOTEBOOKLM_REFRESH_CMD`** is not a credential source on its own; it is the reactive hook that fires after auth has already expired ([§6.2](#62-notebooklm_refresh_cmdcommand-line-l5)). --- ## Recommended setup ### Interactive desktop user Just `notebooklm login`. The Playwright Chromium flow handles it; re-login when prompted (typically days to weeks between prompts). ### Long-lived in-process client (agent, MCP server, worker) ```python async with NotebookLMClient.from_storage(keepalive=600) as client: ... ``` L1 fires on `from_storage()`; L2 fires every 600 s while the client is open. This keeps `*PSIDTS` rotating for as long as the process lives. ### Unattended / headless / server / CI — use the master token (L4) This is the recommended path. No browser at refresh time, survives full cookie expiry, and no `storage_state.json` to keep re-shipping. 1. `pip install "notebooklm-py[headless]"`. 2. One-time, on a machine with a browser (dedicated / throwaway account): `notebooklm -p login --master-token --account you@gmail.com`. 3. Ship the bootstrapped profile — **both** `master_token.json` and the `storage_state.json` the bootstrap just minted (each `0600`) — to the server. (A clean server with *only* `master_token.json` and no `storage_state.json` needs one `notebooklm -p login --master-token-refresh` to mint the initial cookies first; shipping both skips that step.) 4. Run commands normally. Cookies are minted on bootstrap and **re-minted automatically** when the session dies (L4, [§4.4](#44-l4--master-token-re-mint)); force one by hand with `notebooklm -p login --master-token-refresh`. Caveats: the master token is a full-account, infostealer-grade credential — use a dedicated account, keep the file `0600`, never log or commit it. One account is **single-consumer**: N workers re-minting concurrently can invalidate each other's `SID`. The master token inherits the standing DBSC risk (server-minted cookies could be rejected if enforcement extends to this path), but re-mint is itself the mitigation while it isn't enforced. See [ADR-0023](adr/0023-master-token-headless-auth.md) and [installation.md#d-headless-server-or-ci](installation.md#d-headless-server-or-ci). ### Unattended without the `[headless]` extra — browser-cookie extract + cron If you can't or won't use a master token, extract from a real browser and refresh on a schedule: 1. Sign in to NotebookLM once in Firefox (or any rookiepy-supported browser). 2. `notebooklm -p login --browser-cookies firefox`. 3. Schedule L7: `7,27,47 */1 * * * notebooklm --profile auth refresh` (off-minute schedule avoids fleet collision). 4. Keeping the source browser running with a Google tab adds resilience, but even a closed browser works for hours-to-days while `RotateCookies` keeps succeeding from `SID` alone. > **Browser support:** `--browser-cookies` accepts any of the ~16 browsers rookiepy > reads on the host (`arc`, `brave`, `chrome`, `edge`, `firefox`, `opera`, `safari`, > `vivaldi`, …; see `_ROOKIEPY_BROWSER_ALIASES` in > `cli/services/login/cookie_jar.py`). **Firefox is the recommended path on > Windows** because Chrome 127+ App-Bound Encryption makes Chrome reads > admin-or-bust. Scope a Firefox Multi-Account Container with > `firefox::` (unscoped extraction merges every container and can > pick the wrong session); scope a Chromium profile with `chrome::`. ### Workspace / Enterprise with admin session-binding Currently **not supported.** Admin-policy session binding is a Workspace beta that requires DBSC-compatible flows. Request an exemption from your admin or use a personal Google account for automation. --- ## 1 · Problem statement NotebookLM uses Google's internal `batchexecute` RPC. There is no documented API key, no OAuth scope, no service account path. Every project that automates NotebookLM does so with **scraped session cookies** from a logged-in browser. The library exposes those via `notebooklm login` (Playwright-driven Google sign-in into a private Chromium profile) and `notebooklm login --browser-cookies ` (rookiepy-driven extraction from an existing profile). Both produce a `storage_state.json` that authenticates every subsequent RPC. The keepalive question is: **what keeps `storage_state.json` valid between user-driven re-authentications?** The naïve "cookies have expiry timestamps; trust them" answer is wrong on two counts: the most consequential cookie (`__Secure-1PSIDTS`) has a server-side recommended rotation cadence not encoded in its `Expires` attribute (the on-disk `Expires` is irrelevant to server-side validity), and even cookies with a year-long `Expires` are **revoked early by Google's risk model** when the access pattern looks unusual (no JS, no fingerprint, IP changes, long idle gaps). So the library must actively refresh. --- ## 2 · Background: Google session auth, rotation, and DBSC Vocabulary the rest of the doc uses. Skip to [§3](#3--threat-model) if you've already spent time inside Google's identity surface. ### 2.1 The cookie taxonomy Google authenticates a browser session with a **family of ~15 cookies**, not a single bearer token. Each cookie has a distinct role; the family is designed so revoking or rotating any one slot doesn't invalidate the others. The set is shared across `*.google.com` properties — Search, Drive, Gmail, NotebookLM, YouTube, Workspace — which is why a sign-in to any one produces auth artifacts the rest of the ecosystem accepts. Naming conventions: - **`__Secure-` prefix.** The cookie's `Secure` attribute must be set, so it's never sent over plaintext HTTP. Google sets this on every meaningful auth cookie. - **`__Host-` prefix.** Stricter: the cookie must also set `Path=/`, must not set `Domain=` (pinned to the exact issuing origin), and must be `Secure`. Used for the most scope-sensitive cookies (`__Host-GAPS`, `__Host-1PLSID`, …). - **`1P` vs `3P`.** First-party vs third-party context. `__Secure-1PSID` is used when the request originates from a `*.google.com` page; `__Secure-3PSID` is the variant Google sends on third-party pages that embed Google content. They rotate independently. We typically need both because intermediate rotation redirects cross the 1P/3P boundary. - **`*SID` / `*SIDTS` / `*SIDCC`.** Three cookie *families* that separate **identity** (who you are, slow to change) from **freshness** (you're using the session right now, fast to expire): | Family | Role | Recommended rotation cadence | Stale-value validity | |---|---|---|---| | `*SID` (`SID`, `HSID`, `SSID`, `APISID`, `SAPISID`, …) | Long-lived identity | Months → ~1 year | Practically never expires for active accounts | | `*SIDTS` (`__Secure-1PSIDTS`, `__Secure-3PSIDTS`) | Rotating freshness partner of `*SID` | **~600 s** (Google's self-report) | Hours-to-days on a stable IP / non-Workspace profile | | `*SIDCC` (`SIDCC`, `__Secure-1PSIDCC`, …) | Per-request "session continuity check" | Issued on every request | Not enforced for accept/reject | A few cookies sit outside this taxonomy: - **`OSID`, `__Secure-OSID`** — per-product session, set on `notebooklm.google.com` and `myaccount.google.com`. Re-issued on each sign-in. - **`LSID`, `__Host-1PLSID`, `__Host-3PLSID`** — identity-service cookies on `accounts.google.com`. Long-lived. - **`__Host-GAPS`** — anti-takeover binding cookie. Long-lived; part of how Google detects suspicious cross-device reuse. The library treats these uniformly: extract the full set at sign-in, persist them in `storage_state.json`, replay them on every RPC. `_is_allowed_cookie_domain` (in `_auth/cookie_policy.py`, re-exported through `auth.py`) gates which `Set-Cookie` headers from a redirect chain are worth keeping, matching against `ALLOWED_COOKIE_DOMAINS` plus the regional `google.` set. ### 2.2 How cookie rotation works "Rotation" means: the server periodically issues a new value for a short-lived cookie (`Set-Cookie: __Secure-1PSIDTS=; …`), and the browser is expected to overwrite its on-disk copy. If the client falls behind, the server eventually stops accepting the old value and the session is dead until re-auth. Two clocks run in parallel: - The **identity clock** (`*SID`) ticks in months. Google extends it silently as long as it sees activity; for a daily-active user it effectively never expires. - The **freshness clock** (`*PSIDTS`) has a recommended rotation cadence of ~600 s, self-reported in the `RotateCookies` response body as `["identity.hfcr",600]` (`hfcr` = "high-frequency cookie rotation"; `600` = seconds). This is a rotation *hint*, not a hard expiration: stale values keep working for hours or days depending on server-side state, but long-idle sessions eventually drift into sign-in redirects. Rotation is **server-driven**: the client posts to a rotation endpoint; the server inspects the existing `*SID` (and optionally a DBSC proof — see §2.3) and returns a fresh `*PSIDTS`. The client only chooses *when* to fire. **Crucially: pure RPC traffic against `notebooklm.google.com` does not trigger rotation.** `batchexecute` accepts the existing cookies, but Google only mints a fresh `*PSIDTS` when something talks to the *identity* surface (`accounts.google.com`, the NotebookLM homepage GET, the `RotateCookies` POST). A client that only calls `batchexecute` silently drifts past the rotation window and starts failing — exactly what L1/L2 target. We use `RotateCookies` because it rotates deterministically for both browser-bound and Firefox-extracted sessions (see [Appendix](#appendix-field-notes--historical-findings)). ### 2.3 Device-Bound Session Credentials (DBSC) DBSC is Google's response to **infostealer cookie theft**: malware exfiltrates the cookie jar and an attacker replays it from another machine. DBSC binds a session to **a private key in tamper-resistant hardware** (TPM / Secure Enclave / Strongbox) on the original device — the browser generates a non-extractable keypair at sign-in and registers the public key, then signs a server nonce on every rotation. The enforcing endpoint is **`accounts.google.com/RotateBoundCookies`**, the bound-cookie analog of the unsigned `RotateCookies` we use; an attacker who steals the jar can't sign the next bound rotation, so the stolen session dies instead of renewing. The [W3C spec](https://w3c.github.io/webappsec-dbsc/) is deliberately structured so only hardware-attesting browsers can implement it — no Python HTTP client can, and no public OSS DBSC client exists outside Chrome (see [A3](#a3--ruled-out-experiments)). **Current enforcement state.** DBSC is rolling out. Enforcement currently targets **Chrome itself** — Chrome refuses to use cookies that weren't bound at sign-in, even on the same machine. Non-Chrome HTTP clients (httpx, curl, Firefox) can still hit the legacy unsigned `RotateCookies` endpoint without a DBSC proof, so every HTTP-only strategy in this document works today. The day Google extends enforcement to that endpoint, they break together; the in-tree escape is to parasitize a real DBSC-enrolled Chrome session through the L3 CDP attach arm, or to source cookies via an operator-provided `NOTEBOOKLM_REFRESH_CMD` (e.g. CookieCloud federation). See [§7 canaries](#7--canaries-and-signals) for the tripwires that would signal the transition. ### 2.4 How browser-cookie extraction works `notebooklm login --browser-cookies ` reads cookies directly out of an installed browser's profile rather than minting fresh ones via Playwright. It is a **variant of manual login (L6)** and a common backing command for `NOTEBOOKLM_REFRESH_CMD` (L5) — it is **not** a recovery layer of its own, and in particular it is not L4 (L4 is the master token). Browsers store cookies in encrypted SQLite databases, with the decryption key in the OS credential store (Keychain / DPAPI / libsecret). **Chrome 127+ adds App-Bound Encryption (ABE)** — a second layer bound to Chrome's signed binary that defeats user-space readers; `browser_cookie3` doesn't handle it and `rookiepy` needs admin from Chrome 130+ ([rookie#50](https://github.com/thewh1teagle/rookie/issues/50)). **Firefox has no ABE** (Mozilla treats local file-access attackers as out-of-scope), so its cookies stay readable by any user-space process — hence the Windows Firefox recommendation. The library uses `rookiepy` (~16 browsers) and reshapes the result via `_auth/cookies.py::convert_rookiepy_cookies_to_storage_state` into a Playwright-compatible `storage_state.json`, indistinguishable downstream from a Playwright-minted one. Extraction asks for the full multi-domain set (`ALLOWED_COOKIE_DOMAINS + GOOGLE_REGIONAL_CCTLDS`) because dropping any one breaks specific paths (e.g. losing `.notebooklm.google.com` cookies breaks artifact downloads). ### 2.5 Three timers people confuse | Timer | Magnitude | Lives in | Meaning | |---|---|---|---| | **`*PSIDTS` rotation cadence** | ~600 s | Google's identity surface | Recommended active-client refresh interval (`["identity.hfcr",600]`). Not a hard rejection TTL — prior values stay valid much longer on stable profiles. | | **`*SIDCC` sliding window** | ~5 min | Google's RPC surface | A different cookie family; rotates on nearly every request; not load-bearing for our auth. | | **Client-side rotation throttle** | 60 s | `_auth/keepalive.py` | Don't fire two `RotateCookies` POSTs within a minute (avoids 429). Unrelated to how often Google *requires* rotation. | Reports that "cookies are expiring faster" usually trace to the session entering a risk-flagged state (§3.1) or to the rotation mechanism failing until `*SID` finally ages out — not to a shorter hard rejection TTL. ### 2.6 Domain tiering: REQUIRED vs OPTIONAL cookie domains Not every Google cookie a logged-in browser holds is load-bearing for NotebookLM. The library splits the cookie-source domain list into two tiers (`_auth/cookie_policy.py`): | Tier | Constant | Domains | Extracted by default | |---|---|---|:-:| | **REQUIRED** | `REQUIRED_COOKIE_DOMAINS` | `.google.com`, `notebooklm.google.com` (+ regional ccTLDs), `accounts.google.com`, `.googleusercontent.com`, `drive.google.com` | ✅ | | **OPTIONAL** | `OPTIONAL_COOKIE_DOMAINS_BY_LABEL` | `youtube`, `docs`, `myaccount`, `mail` | ❌ (opt-in via `--include-domains=