Back to dev stories
Bug Storiesbugs/2026-06-29-google-search-nid-trust-tier.md

Google Search Trust Tier Is Gated by Mature `NID` Cookie, Not Guest Cookies

Koko dev
June 29, 2026
8 min read

Dev story — work in progress

These posts are engineering notes from building Koko. They capture what we believed at the time — some lessons aged well, some did not. Treat them as a lab notebook, not official docs. Corrections and better ideas are welcome.

Google Search Trust Tier Is Gated by Mature NID Cookie, Not Guest Cookies

Phase 6 in the canonical investigation: google-search-investigation-journey.md. This note is the technical ablation detail behind the warmup conclusion — cookie quality beats cookie count.

Summary

Koko cold-start Google Search without cookies always receives the long path: ~91 KB bootstrap HTML with sclm=false, inline knitsail, and a client-side redirect to sei or sg_ss. Injecting a session NID cookie exported from the user's real Chrome profile flips hop-1 to the short path: ~266–331 KB direct SERP HTML, no gate variables, no knitsail references, and a single document hop. Guest or fresh-Chrome NID does not flip the tier. DV, AEC, __Secure-BUCKET, and __Secure-STRP are helpful session hygiene but are not sufficient alone without a mature NID. The unlock is server-side trust tier selection driven by cookie payload semantics, not client-side fingerprint parity or wire-header cosmetics.


Problem

After weeks of CreepJS parity work, TLS/JA4 alignment, knitsail survival fixes, and pageT tuning, Koko and Chrome still diverged sharply on the same machine, same IP, same query when Koko started with an empty cookie jar:

Engine Hop-1 body sclm Document hops Path
Koko (no cookies) ~91 KB false search → sei long (knitsail)
Chrome curl (user session cookies) ~271 KB absent 1 short (SERP)
Koko (user session cookies) ~266 KB absent 1 short (SERP)

The symptom looked like a client detection failure. Koko's antidetect profile could pass many CreepJS sections while still receiving bootstrap gate HTML. Chrome on the same network, with a warmed profile, got a full results page on the first navigation.

Initial hypothesis: Koko lacks cookies; export Chrome's jar and parity will follow. That was partially true — but which cookies and what trust state they encode mattered far more than simply having any NID present. A guest Chrome spawn that had visited Google once still produced long-path HTML even with four freshly issued cookies. Only the mature NID from a daily-driver Chrome profile flipped the tier.

From a browser architecture standpoint, this is not a rendering bug. Google Search hop-1 is a server-chosen document type delivered over HTTP before any significant client JavaScript runs. The HTML shape — bootstrap shell versus full SERP — is decided by Google's edge using session signals embedded in the cookie jar. Koko's HTTP stack (curl-impersonate, policy-driven headers, cookie injection at session init) was behaving correctly; the missing ingredient was trust-encoded session state that Google's backend recognizes as an established browser profile.


Root Cause

Google applies server-side trust tier selection on cold GET /search requests. The primary gate is the NID cookie payload — a large, periodically rotated value that encodes profile maturity, visit history, and risk scoring state. This is opaque to the client; you cannot infer tier from cookie name alone or from byte length alone (guest NID ≈ 231 chars, mature NID ≈ 280 chars — similar size, different encoded trust).

Tier behavior observed

Cookie state Hop-1 outcome
Mature NID (exported from user's daily Chrome) Full SERP on hop-1 (~266–331 KB)
Guest NID (fresh Chrome, first visit) Bootstrap gate HTML (sclm=false, ussv='', sp='') regardless of AEC, __Secure-BUCKET, __Secure-STRP
No cookies Same as guest — long bootstrap path
DV alone Does not flip tier; with mature NID, DV is optional for short path

Ablation proved NID-only is sufficient for short path when sourced from a real profile. Removing NID from an otherwise complete five-cookie jar restored long path. Removing DV, AEC, BUCKET, or STRP while keeping mature NID left short path intact.

Koko wire behavior (correct, secondary gaps noted)

Koko loads cookies via --cookie at session initialization (src/runtime/cookies.zig). Policy omitCookies: in_session in browser/policies/google-search.json withholds cookies only on sei/sg_ss in-session hops — matching guest Chrome HAR captures. This is not why cold Koko fails; cold Koko fails because hop-1 never receives a mature NID in the first place.

Secondary wire gaps (Sec-Fetch-Site, Sec-Fetch-User) do not block short path when mature NID is present — hygiene only.

Hop-1 tier is chosen before inline bootstrap runs (see google-search-flow-architecture.md). Client knitsail/pageT work cannot upgrade server-assigned bootstrap HTML without the right session cookies.


Investigation

After wire and knitsail fixes plateaued (journey Phase 5–6), we ran controlled cookie ablation.

1. User curl reproduction

curl 'https://www.google.com/search?q=Koko' \
  -b 'NID=532=SYUsm-...; DV=...; AEC=...; __Secure-BUCKET=CFc; __Secure-STRP=...'

Result: 200, ~271 KB, knitsail=0, SERP title — short path without Koko JS.

2. Koko A/B (test-koko-chrome-cookies.mjs)

Case Hop-1 body sclm Hops
No cookie 91 KB false 2 (→ sei)
User 5 cookies 266 KB null 1

Same Koko binary, same profile, same query — only cookie jar differed. Definitive proof that session state gates tier.

3. Cookie ablation (probe-cookie-ablation.mjs)

Base: chrome-real-cookies.json (user session)

Case Tier Body Notes
none long-bootstrap+sei 91 KB baseline
all-5 short-direct 266 KB
NID-only short-direct 267 KB sufficient
DV-only long-bootstrap+sei 91 KB insufficient
NID+DV short-direct 266 KB
all-minus-NID long-bootstrap+sei 91 KB NID required
all-minus-DV short-direct 329 KB DV optional
all-minus-AEC/BUCKET/STRP short-direct 266 KB optional

Base: chrome-cookies.json (fresh Chrome guest)

Case Tier Body
all-4 / NID-only / any subset long-bootstrap+sei ~91 KB

Guest NID never flips tier regardless of combination. Encoded trust state differs from mature profile export.

4. Long vs short hop-1 HTML signatures

Signal Long path Short path
Body size ~91 KB ~266–331 KB
sclm false or true absent
ussv / sp '' absent
knitsail refs 3+ 0
knitsail.a in hop-1 HTML 0 0
Document hops 2 (search → sei) 1
Script count (curl) ~17

These signatures became the fast diagnostic: wc -c on hop-1 response body tells you tier before parsing JavaScript.

5. What we ruled out

TLS/JA4 mismatch alone, CreepJS green sections, and client-side faking of ussv/sp/sclm — tier is server-decided.


Solution

Immediate (operations)

  1. Export cookies from real Chrome profile before Koko search:

    node google-search-debug/scripts/export-chrome-cookies.mjs --chrome-attach
    
  2. Start Koko with session restore:

    zig-out/bin/koko serve --browser-profile <id> \
      --cookie google-search-debug/tmp/chrome-real-cookies.json \
      --cookie-jar google-search-debug/tmp/session-cookies.json
    
  3. Re-export when NID / __Secure-STRP expire (typically days to weeks depending on account activity).

  4. Persist --cookie-jar across runs so rotated cookies survive restarts.

Product (future)

  • Auto-sync cookie jar from attached Chrome CDP at session start.
  • Surface tier diagnostic in debug UI: hop-1 body size + sclm presence.
  • Do not attempt to synthesize mature NID — it is server-issued and cryptographically bound to profile history.

Wire parity (lower priority when NID present)

  • First-hop Sec-Fetch-Site: none — revisit policy priorOrigin forcing same-origin.
  • First-hop Sec-Fetch-User: ?1 — currently blocked by curlDefaultsOnly: first_hop + guest HAR policy.

Lessons Learned

  1. Measure hop-1 body size before debugging knitsail — ~91 KB means cold tier.
  2. Cookie presence ≠ cookie trust — ablate guest vs mature NID.
  3. CreepJS green is layer 4; session cookies are layer 0 (journey doc).
  4. Do not fake sclm/ussv/sp — warm the jar instead.
  5. NID is necessary; DV is optional on the tested profile.
  6. Run probe-cookie-ablation.mjs when tier regresses — jar expiry vs engine regression.

Verification

cd /Users/huydev/Desktop/koko-core

# Ablation (11 cases, ~3 min)
node google-search-debug/scripts/probe-cookie-ablation.mjs \
  --base google-search-debug/tmp/chrome-real-cookies.json

# A/B no-cookie vs session cookies
node google-search-debug/scripts/test-koko-chrome-cookies.mjs \
  --no-export --cookie-file google-search-debug/tmp/chrome-real-cookies.json

# Quick tier sniff on live navigation
node google-search-debug/scripts/run-google-search-with-session.mjs --query "Koko"

Pass criteria:

  • NID-only ablation case → short-direct, body > 250 KB, sclm absent.
  • all-minus-NIDlong-bootstrap+sei, body ~91 KB.
  • Guest jar base → all cases long path regardless of subset.

Artifacts:

  • google-search-debug/tmp/cookie-ablation-*/report.json
  • google-search-debug/tmp/cookie-ab-*/report.json
  • google-search-debug/tmp/chrome-real-cookies.json

References


Related Knowledge

Source

knowledge/bugs/2026-06-29-google-search-nid-trust-tier.md

Snapshot published Jun 30, 2026. The canonical note may evolve in the repo; this page stays as the story we shipped that day.