2026-06-19
A Local Trial App for Understanding PCI & Tokenization (Built as an AI Chat Client)
Why: A Sandbox for PCI and Tokenization
I work on banking and fintech products, where the rules around handling sensitive data — PCI DSS, tokenization, "the client must never see the secret" — are non-negotiable but easy to treat as checklist items you inherit from a platform team. I wanted to actually internalize the patterns: build them from scratch, break them, and see why each control exists.
So I built a deliberately localized trial app — a mock AI chat client — purely as a testbed. No real cardholder data is ever involved. The chat is a stand-in; the point is the data-handling architecture around it. It let me reproduce, in miniature, the questions that matter in a payments system:
- Where does the secret live, and who is allowed to see it? (tokenization's core question)
- What is in scope for a breach, and how do I shrink that surface? (PCI scope reduction)
- Is sensitive material encrypted at rest, and what happens when the keystore is unavailable?
- Can an untrusted UI layer ever reach raw secrets or persistent storage?
A mock AI chat app turns out to be a perfect vehicle: it has an auth credential to protect, a clear trusted/untrusted boundary (main process vs. renderer), persistent data, and a packaging story that maps directly onto "what is in scope." Build it like it holds card data, and the lessons transfer.
The Shape of the Trial
A cross-platform Electron desktop app where everything runs on localhost — three cooperating processes, no cloud, no data leaving the machine:
- The Electron app — a Node main process (trusted) plus a sandboxed renderer (untrusted).
- PocketBase — a single-binary backend on
127.0.0.1:8090holding users, conversations, and messages. - A mock AI server — a tiny Node HTTP service on
127.0.0.1:8787standing in for a model API.
Keeping it entirely local is itself a PCI lesson: the most reliable way to keep data out of scope is to never let it leave the trust boundary in the first place.


Tokenization, Modeled: The Secret Never Reaches the UI
The heart of tokenization is substitution — downstream systems handle a surrogate, never the sensitive value, so the real secret lives in exactly one tightly controlled place. I modeled that with the auth token as the "sensitive value."
The token lives only in the trusted process. It's held in main-process module state, attached to backend requests there, and persisted encrypted at rest with Electron's safeStorage to userData/auth.bin. The renderer — the large, untrusted, dependency-heavy surface — never receives it. It works entirely with session state, never the credential itself:
// Main process owns the secret; the renderer is handed only what it needs.
let authToken: string | null = null // never crosses the IPC boundary
async function persist(token: string) {
if (safeStorage.isEncryptionAvailable()) {
await writeFile(authPath, safeStorage.encryptString(token))
}
// No keystore? Degrade to a non-persistent session — never store plaintext.
}
That last line is the kind of edge case PCI forces you to think about: if you can't encrypt, you do not silently fall back to storing the secret in the clear — you accept a degraded (non-persistent) experience instead. The token is never logged, never sent across IPC, never written unencrypted.
Mapped back to payments: the renderer is every system you keep out of scope by handing it a token instead of a PAN. The main process is the small, audited vault that alone holds the real thing.
Scope Reduction: Treating the Renderer as Hostile
PCI scope reduction is about shrinking the set of components that can ever touch sensitive data. In an Electron app, the renderer is the obvious thing to push out of scope — it runs third-party UI dependencies and renders untrusted content (markdown). So it's treated as hostile by construction.
The renderer is sandboxed. sandbox: true, contextIsolation: true, nodeIntegration: false. No Node, no require, no filesystem. (A sandboxed preload must be CommonJS — a real constraint I accepted to keep the sandbox on.)
Every IPC call returns a typed Result<T>. Exceptions never cross the process boundary; handlers convert any throw into a structured failure:
ipcMain.handle('chat:send', async (_e, args): Promise<Result<ChatSendResult>> => {
try {
return { ok: true, data: await chat.send(args) }
} catch (err) {
return { ok: false, error: toMessage(err) }
}
})
"The untrusted layer cannot crash or destabilize the trusted layer" becomes a structural property, not a per-call discipline. A mismatch between a handler and its caller is a compile error, because the Api interface is shared across the boundary.
CSP is enforced at runtime. A strict production policy (no unsafe-eval) is set in onHeadersReceived, with a looser dev branch only for Vite's HMR — so the relaxations needed to develop never leak into the shipped build.
Defense in Depth on Writes: Honest Rollback
PCI cares about data integrity, not just confidentiality. The send flow is: persist the user message → call the AI → persist the assistant reply → bump the conversation metadata. If anything after the first persist fails, the handler deletes the orphaned message and returns a clean failure; the UI rolls back the optimistic bubble and restores the draft. No half-written, unrecoverable state — the system is always in a state you can reason about and retry.
"In Scope" Means The Whole Release: Bundling The Backend
This is where the trial taught me the most about scope. In development you run three things by hand:
yarn pb:start # PocketBase on :8090
yarn mock-ai # mock AI on :8787
yarn dev # the Electron app
Then I built the macOS release, double-clicked it, and got:
Cannot reach server. Is PocketBase running?
The packaged app bundled neither backend and started neither. Fix that, and the next layer surfaced the identical failure: Cannot reach AI server. The lesson is a PCI lesson in disguise: your security and data boundary is whatever you actually ship, not what your dev environment happens to have running. An audit of "my machine" tells you nothing about the artifact a user runs.
So the trial had to package its entire data plane:
Bundle the binaries outside the asar. You can't execute a binary from inside an asar, so both backends ship as extraResources:
extraResources:
- from: pocketbase
to: pocketbase
filter:
- pocketbase # macOS / Linux
- pocketbase.exe # Windows
- pb_migrations/**
- from: mock-ai/server.js
to: mock-ai/server.js
Spawn them from the trusted process, and tear them down. The main process starts both before the window opens, points PocketBase's database at a writable userData directory (Resources is read-only in a signed app — itself a least-privilege property), runs the mock AI on Electron's own binary as Node via ELECTRON_RUN_AS_NODE (no system Node required), health-gates the UI on /api/health, and kills both on will-quit so nothing outlives the app:
const child = spawn(process.execPath, [serverScript], {
env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' },
stdio: 'ignore'
})
Build it the same on every platform. The PocketBase binary is gitignored, so CI fetches the per-runner build (OS + arch detection, with a PowerShell unzip fallback on Windows) before packaging. A tag push fans out across macOS, Windows, and Linux and publishes self-contained, double-click-to-run installers.
What I Learned
Tokenization is an architecture, not a function. The value isn't "call a tokenize() method" — it's the discipline of keeping the real secret in one small, trusted, encrypted place and handing everything else a surrogate. Modeling that with an auth token made the principle concrete.
Scope is whatever you ship. Three terminals I opened on autopilot in dev were three components the release didn't know about. The packaged build is the only honest test of your actual data boundary — the same way a PCI assessment cares about the deployed system, not the developer's laptop.
Never silently downgrade a security control. When the keystore is unavailable, dropping to a non-persistent session is correct; quietly storing the token in plaintext would not be. Failure modes are where controls are really tested.
"Untrusted by construction" beats "careful by convention." A sandboxed renderer and a typed Result<T> boundary make the safe behavior structural. You don't have to remember to be careful at every call site — the architecture won't let you not be.
Tech Stack
App: Electron, TypeScript, React 19, Zustand, Tailwind CSS, shadcn/ui, react-markdown
Backend: PocketBase (single-binary, REST over fetch, no SDK), a Node HTTP mock AI server
Security model: sandboxed renderer, context isolation, runtime CSP, safeStorage-encrypted token (tokenization-shaped trust boundary), typed Result<T> IPC, fully local data plane
Build & CI: electron-vite, electron-builder, GitHub Actions matrix (macOS / Windows / Linux), tag-triggered releases
This was a learning vehicle, not a payments product — no real cardholder data is involved. But every control in it maps to one I rely on in production banking work, and building them from scratch on a throwaway chat app made the why behind each one stick.
For Developers
The whole thing is open source — clone it, read the IPC and safeStorage code, or grab a pre-built installer and watch it spawn its own backend on launch.
- Source code: github.com/krkrishnaraj11/ai-chat-desktop
- All releases: github.com/krkrishnaraj11/ai-chat-desktop/releases
- Latest (v1.0.1): github.com/krkrishnaraj11/ai-chat-desktop/releases/tag/v1.0.1 — macOS (
.dmg), Windows (.exe), Linux (.AppImage/.deb)