A single XSS can kill your startup
Everyone talks about churn from bad UX, slow load times, confusing onboarding, a button that doesn't work on mobile. The discourse around "losing users" is almost entirely product-focused.
Nobody talks about what happens when your update endpoint serves a javascript: URI to every user who checks for a new version. Or when your release notes field, the one that accepts markdown, gets used to inject a script tag into every client that renders it. Or when your static release token has no rate limiting, so an attacker has unlimited attempts to brute-force it.
These aren't theoretical scenarios, they're the kinds of findings that show up in a routine PR audit on a Tool version management system. And unlike a broken modal or a confusing signup flow, you don't get a Hotjar recording that tells you something went wrong. You get a breach report or you get silence, and find out six months later.
The vulnerability that doesn't look like one
Here's what a real pull request looked like recently, flagged during a security audit:
class ArtifactCreate(BaseModel):
platform: str
arch: str
url: str # accepts any URI
sha256: Optional[str] = None
Four fields, No validation, The url field accepts https:// but it also accepts javascript:alert(document.domain), data:text/html,<script>..., and http://internal-svc:8080/admin.
When your Tool auto-update endpoint returns this URL and a client renders it, that's XSS. When your own Tool follows the URL to download a binary, that's SSRF and depending on your cloud setup, that could mean access to internal services, metadata endpoints, or worse.
The fix is four lines:
from typing import Literal
from pydantic import HttpUrl
class ArtifactCreate(BaseModel):
platform: Literal["linux", "darwin", "windows"]
arch: Literal["amd64", "arm64"]
url: HttpUrl
sha256: Optional[str] = Field(None, min_length=64, max_length=64)
But the point isn't the fix. The point is that this code shipped, reviewed, merged, deployed because nobody was looking at it from a security perspective. The developer wasn't careless, they were moving fast.
That's the context in which most startup code gets written.
What a compromised release token actually costs you
The same PR had a second issue: a static TOOL_RELEASE_TOKEN with no rate limiting on the authentication endpoint and no rotation mechanism.
Walk through what that means in practice:
An attacker brute-forces the token, no lockout, no throttle, unlimited attempts. They now have the ability to publish a fake Tool version with a malicious download URL.
Your users run tool update, they download and execute a binary from an attacker-controlled server and every machine that auto-updated is now compromised.
You don't know until someone reports it or until you see it in your access logs and realize what happened or until a security researcher publishes it.
The business cost isn't just remediation, it's the announcement you have to send to users, it's the news cycle if you're big enough, it's every enterprise customer who now has a security incident on their hands because of your tool. It's the contracts that get paused pending a security review. It's the investors who ask uncomfortable questions on the next call.
A static token with no rate limiting isn't a medium-severity finding, It's a supply chain attack waiting for someone with time and motivation.
Stored XSS is slower and worse
XSS via a javascript: URI is dramatic, stored XSS is quieter and harder to detect.
The same audit found that release_notes, a markdown field returned in API responses, was stored without sanitization. Any client that renders this content as HTML executes whatever's in it.
The attack vector: whoever controls the release token (or whoever gains access to the GitHub Action that uses it) can inject a payload into release_notes. It sits in your database and every user who queries your version endpoint gets it back.
If they're using a web-based dashboard, an Electron app, or any client that renders markdown to HTML, the script runs.
The insidious part is that this doesn't trigger on write, it triggers on read, potentially weeks or months later, across every client simultaneously.
Sanitizing on storage takes ten minutes:
import bleach
ALLOWED_TAGS = bleach.sanitizer.ALLOWED_TAGS | {"p", "pre", "code", "h1", "h2", "h3"}
def sanitize_release_notes(content: str) -> str:
return bleach.clean(content, tags=ALLOWED_TAGS, strip=True)
The gap between "ten minutes to fix" and "six months of exposure" is that nobody thought to add it during the initial implementation. That gap is where startups get hurt.
The real question is what you're not seeing
These findings came from auditing one PR on one endpoint. A Tool version management route, not the authentication system, not the payment flow, not the data pipeline. One endpoint.
In our experience analyzing production codebases, the ratio holds pretty consistently: for every vulnerability someone finds during a focused review, there are several more in the surrounding code that nobody was looking at. Not because the team is negligent, but because security review requires a different mode of reading than code quality review.
A senior engineer reading this PR would catch the missing Literal types, probably flag the str on url as sloppy. But they'd need to be specifically thinking "what can an attacker do with this?" to trace the full exploit chain from a malicious URL in the database to code execution on user machines.
That's not a criticism of how developers read code, it's just a different skill set, applied at a different moment. Tools like Ixtl exist specifically to run that analysis at PR time before the code ships, while it's still cheap to fix, without requiring a dedicated security engineer on the team.
What it actually takes to close a startup
You don't need a nation-state attacker, You don't need a zero-day, You need:
- One leaked GitHub Actions secret
- One endpoint with no rate limiting
- One Tool that auto-updates
And you can push a malicious binary to every user who runs your tool.
For a startup with enterprise customers, that's not a security incident, it's an existential one. One customer's security team reports it, the others hear about it, You spend the next quarter doing damage control instead of building product.
| Vulnerability | Attack Vector | Business Impact |
|---|---|---|
| Unvalidated url field | XSS / SSRF | User machines compromised via auto-update |
Stored XSS in release_notes
|
Payload delivery at scale | All clients exposed simultaneously |
| No rate limiting on token auth | Brute-force → full access | Supply chain compromise |
| Static token, no rotation | Indefinite exposure post-leak | No recovery path without redeployment |
The conversation about why startups fail is almost entirely about product, market fit, and runway. Security failures don't show up in postmortems because most companies don't publish postmortems when they get breached.
They just quietly disappear, or spend years rebuilding trust they didn't know they were losing.
What to do before you ship
None of this requires a security team, it requires asking a different question during code review: what happens if someone uses this input maliciously?
For the specific issues above, the checklist is short:
- Validate URL schemes, HttpUrl in Pydantic, or a custom validator that blocks everything except https:// to known domains
- Sanitize any field that stores user-controlled content and gets returned in API responses
- Add rate limiting to any endpoint that uses token-based auth, 5 requests per minute is enough to make brute-force infeasible
- Rotate static secrets; use GitHub OIDC for CI/CD instead of long-lived tokens when possible
These are 30-minute fixes, individually. The hard part is knowing you need them before someone else figures it out first.
If you want a second opinion on what's in your PRs before they merge, ixtli.app runs this kind of analysis automatically not just on the diff, but across the full dependency graph of the project. The findings above were caught before the code shipped.
That's the only time catching them is cheap.
--
If this was useful, share it with the engineer on your team who reviews PRs. Not because they're doing something wrong but because this is the kind of thing that's easy to miss and expensive to learn the hard way.

Top comments (0)