How StockAgent keeps your portfolio private (technical breakdown)
One question I keep getting: "How do I know my data is actually private?"
Here's the full picture; it's enforced at three layers, not just one:
Layer 1: Two-tier storage architecture
Your data is split into two buckets in chrome.storage.local:
Private tier (never transmitted): holdings (share counts, avg buy prices), geminiApiKey, autoAnalyze
Cloud-eligible tier (only sent if you opt into email): watchlist (ticker symbols only), email, schedule, timezone
These two tiers are stored in the same Chrome sandbox but are treated as completely separate data paths in the code.
Layer 2: Allowlist-only payload construction
When you click "Save & Subscribe," the extension doesn't copy your local state and strip private fields, it reconstructs the outbound object from scratch using only allowed fields:
export function buildCloudPayload(state) {
return {
email,
watchlist, // ticker symbols only
schedule,
enabled,
userId,
};
}Holdings, buy prices, and API keys are never referenced in this function. They can't leak because they're never included to begin with.
Layer 3: Runtime blocklist enforcement
Even after the allowlist construction, every payload passes through assertNoPrivateLeak() before it hits the network. This function walks the entire object (including nested properties) and hard-throws if any of these keys appear:
const FORBIDDEN_CLOUD_KEYS = Object.freeze([
"holdings",
"shares",
"buyPrice",
"avgBuyPrice",
"geminiApiKey",
"geminiKey",
"apiKey",
"autoAnalyze",
"netWorth",
"portfolio",
]);It also enforces a root-level allowlist, if any unexpected key exists at the top level (even a typo or a future field added carelessly), it refuses to transmit:
const allowed = new Set(["email", "watchlist", "schedule", "enabled", "userId"]);
for (const key of Object.keys(payload)) {
if (!allowed.has(key)) {
throw new Error(`Refusing unexpected cloud field: ${key}`);
}
}Layer 4: Backend rejects it too
Even if somehow a malicious payload got past the extension (impossible without modifying the source), the FastAPI backend uses Pydantic with extra="forbid" on every schema. Any field not explicitly defined in the schema is rejected with a 422 error before it touches the database:
model_config = ConfigDict(extra="forbid")So if someone tried to POST {"email": "...", "holdings": {...}}, the API would reject it outright.
Summary: Private data is protected by construction (never included), by runtime assertion (hard-fail if present), and by server-side validation (rejected even if it arrived). Three independent layers, any one of them alone would be enough.
Don't take my word for it, the source is public. The privacy enforcement lives in
storage.js (client-side) and schemas.py (server-side).

Replies