How I built a prompt-injection-resistant AI assistant for my portfolio
Why I built this
The little chat box on this site talks to a real large language model. It has a system prompt, a knowledge base about me, and one job: answer questions about my work. Because it lives on the open internet, it also attracts the usual crowd of people who want it to do something else — write their homework, roleplay a jailbroken persona, cough up its own instructions, or say something regrettable so they can screenshot it.
None of this is unique to my site. But being a personal project, it's small enough that I got to think through the whole security story end-to-end, from the frontend affordance all the way to the model response. This is a long write-up of what I did, why I did it, and what I deliberately skipped.
The rest of this post is organized as follows. Sections 1–2 cover the design philosophy and the stack. Sections 3–8 walk through the six defensive layers in the order a request encounters them. Sections 9–11 cover the LLM provider chain, the knowledge base design, and the streaming layer. Sections 12–13 cover the libraries I evaluated and skipped, and the honest caveats.
1. Design philosophy — layers, not walls
A single hardened prompt is a wall. A wall gets scaled. What you actually want is a doorman who checks the guest list, a well-briefed host inside, and a bouncer who reads the room on the way out — none of them perfect, all of them cheap.
The most useful reframing I did early on was to stop trying to make any single component "prompt-injection-proof." Every published defense against prompt injection has been broken by someone, somewhere. What layered defenses actually buy you is not invincibility; it's a narrower blast radius. If the model gets jailbroken on turn N, the input guard on turn N+1 still fires. If the input guard misses, the hardened prompt still refuses. If the model refuses badly, the output guard rewrites it. If all three fail, the abuse tracker is counting and the shadow-ban kicks in. None of these are individually clever. Together they're annoying enough that the bored teenager with a Reddit jailbreak template gives up and asks a real question.
The other thing I gave up early: the goal is not to say something impressive when jailbroken. The chatbot has no tool use, no memory across sessions, no database writes, no PII, no filesystem access. Its knowledge base is my public portfolio content — the same words are already on the site. The worst thing a successful jailbreak achieves is a briefly awkward answer. So most of the design effort went into making that briefly awkward answer unlikely and quiet, not into unbreakable defenses.
2. Stack at a glance
The request path for a single question, at a very high level:
[Browser]
↓ fetch("/api/chat/stream", SSE)
[Next.js frontend] (just an affordance — no LLM logic lives here)
↓ same URL, cross-origin to api.*
[NestJS backend]
↓ Layer 1: input guard
↓ Layer 2: hardened system prompt
↓ Provider chain (Gemini → OpenRouter fallback)
↓ Layer 3: output guard
↓ Layer 4: rate/budget/shadow-ban bookkeeping
↓ Layer 5: structured log line
[Browser] streams response frame-by-frame
3. Layer 1 — Input guard (before the model)
The first defense is a set of cheap, deterministic checks that run on the raw user question before any LLM call. This is the single highest-leverage layer, because it turns a large class of attacks into a canned refusal at zero LLM cost.
The service performs deterministic preprocessing and validation before the model. This includes cleaning the input text of common obfuscation techniques, checking for unusual entropy or formatting, and validating the intent against a set of known malicious patterns. Based on these checks, the system either allows the request, flags it for heightened skepticism, or rejects it outright before it ever reaches the LLM.
The important design choice: high-confidence matches skip the LLM entirely. That means a determined attacker gets no signal from LLM behavior — they can't tell if their attack "worked partially" or "confused the model," because the model never saw it. All they see is a plain refusal in the site's voice.
4. Layer 2 — Hardened system prompt
If the input clears Layer 1, it goes to the model. The system prompt is designed to survive contact with a hostile user without leaking anything interesting.
The prompt relies on strong structural isolation between instructions and user data, ensuring that the model understands the boundary between the two. The instructions clearly establish that user input is untrusted data, never a command.
Additionally, the prompt contains baked-in example refusals in the exact tone the site uses (dry, understated, redirecting to on-topic questions). Without these, the model invents its own refusal style — which is often either preachy ("As an AI assistant, I cannot…") or apologetic in a way that doesn't match the site. Few-shot examples move the needle more than another rule ever will.
5. Layer 3 — Output guard (after the model)
Once the model finishes streaming, but before the response goes to the browser, a second guard inspects the full accumulated output. It validates the response against a set of constraints to ensure the model hasn't leaked internal system details or generated structural anomalies.
If any of those fire, the response is discarded and a canned refusal takes its place. Importantly, the refusal looks identical to a Layer 1 refusal from the visitor's perspective — same voice, same format, same delivery mechanism. This is deliberate. A probing visitor can't distinguish "the input filter blocked me" from "the model said something weird and got rewritten," which removes a signal they'd otherwise use to iterate their attacks.
I made a conscious tradeoff here: the output guard runs on the full accumulated response, not mid-stream. That means for the brief moment before the guard fires, the visitor is seeing streamed tokens that will later be replaced. Running the guard mid-stream would catch problems earlier but would also block streaming entirely on false positives. For a portfolio Q&A that's the wrong tradeoff.
6. Layer 4 — Abuse tracking
The system uses standard abuse-tracking mechanisms backed by Redis. This handles bursts, mildly rude traffic, and sustained probing attempts. Once an offender exceeds acceptable usage patterns or repeatedly triggers defensive layers, the system silently degrades their experience by serving instant, cached refusals without engaging the LLM.
None of this stores the raw IP — every counter uses an anonymized identifier. The privacy tax is essentially zero and the defensive semantics still work.
7. Layer 5 — Observability
Every request produces a single structured log line: anonymized identifier, outcome slug, category, latency, provider name, response character count, turn count. Something like:
[chat] id=anon_123 outcome=ok category=ok turns=0 latency=1234ms provider=primary chars=203
Outcome slugs cover the interesting cases. I deliberately did not build a database of rejections. For a personal site that would just add operational overhead — I care about "am I getting more bad traffic this week than last week" not "who exactly was doing what." The log line answers that question with grep and wc -l.
8. Layer 6 — Refusal voice
This one's smaller than the others but matters more than you'd think. Every refusal from any layer picks from a small pool of blunt one-line templates in the site's voice. Refusals never:
- Mention "AI," "model," "system prompt," "safety," or "rules"
- Explain why they refused (that's a fingerprint attackers iterate against)
- Sound preachy or apologetic
They do redirect: "That's outside what I can talk about — try asking about Swapnil's work?" is a much better refusal than "I cannot help with that request." The redirection gives the visitor a real next step, and reads as human rather than as a canned safety response.
The system cycles through different templates so a persistent user sees varied responses rather than the same line twice in a row. This is a small touch but noticeably reduces the "obviously scripted" feel.
9. Provider chain
The service uses multiple providers with automatic failover. The provider interface is deliberately tiny — one method, streamChat, returning AsyncIterable<string>. Adding a new provider is straightforward, and the system transparently converts the internal history format to whatever the active provider API expects.
10. The knowledge base
The chatbot answers from a small set of markdown files: about, experience, projects, skills, education, and each published blog post. All in first-person-third from the site's perspective ("Swapnil built…").
The design decision here is that the knowledge base is short enough to fit into the model's context window with room to spare. Retrieval is not a "find the top-k chunks" problem — it's "put everything into the prompt and let the model attend to what matters." This eliminates a whole class of retrieval failures.
11. Streaming — Server-Sent Events (SSE)
The endpoint streams tokens as they arrive from the LLM. The difference between "spinner" and "watching words appear" is enormous for perceived quality.
The backend exposes a clean streaming interface, and the frontend consumes it natively without pulling in heavy third-party streaming libraries. This setup gives full control over the exact wire format, allowing the frontend to gracefully render errors as inline messages instead of abruptly yanking the connection.
12. Libraries I evaluated and did not use
At various points I considered adding:
Llama Guard / Rebuff / Lakera Guard. These are excellent for enterprise LLM apps that touch private data or execute actions. For a portfolio Q&A with a public KB and no tool use, an additional classifier LLM in front of the main LLM adds ~500ms latency and doubles the cost per request to reduce risk that's already low. The math doesn't work out. If I ever add "book a meeting" or "send email" via the chatbot, this reasoning flips.
Nvidia NeMo Guardrails, Guardrails AI, Instructor. These are structured-output frameworks. My use case is free-form conversation, not JSON schema enforcement. Wrong tool.
semantic-router or an embedding-based topic classifier. This is the one I'd most likely add next. Instead of pattern-matching for "off-topic," you embed the question and check cosine similarity against a small set of on-topic examples. Better generalization, harder to bypass with paraphrase. The reason I haven't added it yet: I want a week or two of real logs to build the on-topic example set from actual visitor questions, not from imagination. That is a Phase 2 upgrade.
Radix / shadcn scaffolding for the dialog. The site already has a Motion-based UI vocabulary, and pulling in heavy dependencies for a single modal is the wrong call. I built a custom Dialog primitive with the same a11y contract (focus trap, ESC, aria-modal, scroll lock, focus return on close). The API is shadcn-shaped so if I ever swap the internals for Radix, call sites don't change.
Closing
The most honest security posture at this scale is: narrow scope, quiet refusals, layers instead of promises. Assume you'll be probed. Design so that the reward for probing is boring.
The whole feature is one modal on my portfolio's home page. It answers questions about my work. It cannot do anything else, and if you try to make it, you'll get a polite one-liner steering you back to what it can do. That's the entire product spec, and every architectural decision here is downstream of it.
If you're building something bigger — a chatbot with tool use, memory, or PII access — most of this still applies, but you'd add a classifier LLM at the gate and reconsider the "one output guard is enough" call. For a portfolio, this is the honest shape.
Discussion
Comments coming soon — building something custom.