Startup security for a team of three: the 5 controls that actually stop breaches — MFA, secret scanning, Firebase rules, least privilege, tested backups. Skip the theater.
A vendor once sent us a 210-item security questionnaire before they'd sign. We were three engineers and a founder, working out of a Dubai apartment with good AC. Question 47 asked about our "data center physical access logs." We don't have a data center. We have Firebase.
I filled out that questionnaire honestly and it cost me the better part of two days. Most of it was theater — questions written for a company with a SOC, a compliance officer, and a floor of servers, aimed at a company with none of those. That questionnaire is a perfect snapshot of the trap small teams fall into with security. You either ignore it entirely because the "proper" version looks impossible, or you cargo-cult the enterprise checklist and burn weeks you don't have on controls that protect you from almost nothing. Both are wrong. There is a small, specific set of security work that genuinely lowers your risk at this size, and a large pile of work that is negative ROI until you're much bigger. This post is me ranking them from the field, after six years of shipping products where the whole security team was also the whole engineering team.
If you take one thing away: startup security is a prioritization problem, not a coverage problem. You will never cover everything, so the entire game is spending your scarce hours on the controls that map to your actual threats. Everything below flows from that.
The standard guidance — ISO 27001, SOC 2, the CIS Top 18, whatever your enterprise client's procurement team faxes over — quietly assumes a few things you don't have. It assumes headcount to own controls. It assumes separation of duties, so no single person can both write and deploy the same code. It assumes an incident response team, a review cadence, and money for tooling whose main output is evidence for auditors.
You have three people who each do six jobs. Your "separation of duties" is that Amir is busy on Tuesdays. Implementing enterprise security as-written on a team of three doesn't make you more secure — it makes you slower, and slow is its own kind of risk when you're pre-revenue and every week counts.
So the framing I use isn't "are we compliant." It's a blunter question: what could actually take this company down, and what's the cheapest thing that stops it? Everything flows from answering that honestly instead of answering a questionnaire. Compliance is a byproduct you buy later, once someone is paying you enough to make it worth the paperwork.
When engineers picture getting hacked, they imagine a hoodie and a zero-day. The reality for a small startup is much more boring, and boring is good news, because boring is cheap to defend against. This is threat modeling in its most useful form — not a formal STRIDE workshop, just an honest inventory of who actually attacks companies your size and how.
Nobody is writing custom exploits for your Flutter app. You are not an interesting target as a company. You are an interesting target as one member of a huge herd of small companies that all get sprayed by the same automated attacks. The threats that actually hit teams like ours, roughly in order of how often they land:
allow read, write: if true "just for testing," shipped to production, never revisited.Notice what's not on that list: nation-states, sophisticated network intrusion, physical breaches, side-channel timing attacks. Those are real, but they are not your risk profile. Spending your limited security budget on them is like a corner shop installing missile defense while leaving the till open on the counter.
The pattern across all five is worth naming: almost every realistic breach of a small company is an access or configuration failure, not a clever exploit. Somebody got a credential they shouldn't have, or found a door someone forgot to lock. That single observation is what makes the next section possible — if the threats are boring and access-shaped, the defenses can be cheap and access-shaped too.
If you do only these five things, and do them properly, you have eliminated the overwhelming majority of realistic ways a company your size dies from a security event. I'm ranking them by return on the hour you spend, because at three people the hour is the scarce resource, not the money.
This is number one and it isn't close. Nearly every real breach of a small company I've heard about up close traces back to a stolen or reused credential. The fix is embarrassingly cheap:
A word on phishing-resistant MFA, because not all second factors are equal. SMS codes and even app-based TOTP codes can be phished — a convincing lookalike page just asks for the six digits and relays them in real time. Hardware security keys and passkeys (WebAuthn/FIDO2) can't be phished that way, because the browser cryptographically binds the login to the real domain. For your two or three highest-value accounts — the cloud root account, the GitHub org owner, the domain registrar — spend the money on hardware keys. For everyone and everything else, an authenticator app is a fine floor.
The one people forget is the domain registrar and DNS. If someone takes your domain, they can redirect your email, pass domain-validation checks, and issue TLS certificates in your name — game over for your identity, and none of it touches your app's login screen. Lock it down with MFA and a registrar lock. I watched a company nearly lose their brand because their domain sat on a founder's personal Gmail with no 2FA, and the founder had used that same Gmail password on three other sites.
Assume anything committed to git is public forever, because from a risk standpoint it might as well be. On a recent project I ran a scanner over our full history and found two dead API keys and a Firebase service-account file someone had committed 14 months earlier "temporarily." The keys were dead; the service account was very much alive. All rotated within the hour, then I spent the evening wondering what else was in there.
Concretely:
gitleaks takes ten minutes to wire up.# .pre-commit-config.yamlrepos: - repo: https://github.com/gitleaks/gitleaks rev: v8.18.0 hooks: - id: gitleaks
And in CI, so protection doesn't depend on every machine remembering to install the hook:
# .github/workflows/secrets.ymlname: secret-scanon: [push, pull_request]jobs: gitleaks: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: { fetch-depth: 0 } - uses: gitleaks/gitleaks-action@v2 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}The fetch-depth: 0 matters — without full history the scanner only sees the latest commit, and the whole point is catching the key someone buried 200 commits ago.
One trap worth calling out: when a secret does leak, deleting the file is not the fix. The commit is still in history, and any clone or fork keeps a copy. The only real remediation is to rotate the credential — revoke the leaked key and issue a new one — so that even if an attacker already scraped it, it's now worthless. Scrubbing git history with something like git filter-repo is worth doing as cleanup, but rotation is the step that actually closes the hole. Treat any secret that ever touched a commit as burned.
If you're Firebase-heavy like us, your security rules are your backend. There is no server-side gatekeeper between the client and the data except those rules, so a permissive rule is the single most common way a small app leaks its entire user table to anyone with the SDK and ten minutes. The mistake is treating rules as an afterthought instead of as the actual perimeter they are.
Two habits fix this. First, deny by default and grant narrowly. Second, test the rules — the emulator lets you write real assertions, so a bad rule fails CI instead of failing your users.
// firestore.rules — deny by default, then grant narrowlyrules_version = '2';service cloud.firestore { match /databases/{database}/documents { match /users/{userId} { // A user can only read/write their own document. allow read, write: if request.auth != null && request.auth.uid == userId; } match /orders/{orderId} { allow read: if request.auth != null && resource.data.ownerId == request.auth.uid; allow create: if request.auth != null && request.resource.data.ownerId == request.auth.uid; allow update, delete: if false; // mutate server-side only } // Anything not matched above is denied. }}The allow update, delete: if false line is deliberate. Not everything needs to be writable from the client, and the narrowest door is the safest one — if the client never needs to edit an order, don't let it. Push those mutations through an admin path where you control the logic. And write the emulator test, because rules are code and untested code is a guess:
// firestore.rules.test.js — runs against the emulatorconst { assertFails, assertSucceeds } = require('@firebase/rules-unit-testing');test('a user cannot read another user\'s doc', async () => { const alice = testEnv.authenticatedContext('alice'); await assertFails(alice.firestore().doc('users/bob').get());});test('a user can read their own doc', async () => { const alice = testEnv.authenticatedContext('alice'); await assertSucceeds(alice.firestore().doc('users/alice').get());});A few rules-specific traps that bite small teams over and over:
role or isAdmin field, assert in the rule that the incoming write can't change it — for example request.resource.data.role == resource.data.role — or the client can promote itself to admin with one SDK call.get() and exists() in rules cost reads and add latency. Cross-document lookups are sometimes necessary (checking a membership doc, say), but each one is billed and slows every request. Model your data so the common path needs zero lookups.allow read, write: if true there leaks user uploads just as badly as Firestore leaks documents. Audit both.One more thing that pays off twice: if a screen reads 40 documents to render, that's both a cost problem and a wider surface to reason about. I've cut screens from ~40 reads down to 3 by modeling data properly, and the tighter model is genuinely easier to secure — fewer collections touched means fewer rules to get right.
The person most likely to hurt you is someone you trusted last quarter. Usually not out of malice — just an account that never got shut off. On a team this small you don't need a fancy IAM strategy. You need a list, and the discipline to run it every single time someone leaves.
The shared-credential rotation is the step everyone skips, and it's the one that matters most. Removing a user's account does nothing if there's a shared login whose password they memorized on their second week. If they touched it, rotate it — that's the whole rule.
While you're thinking about access, extend least-privilege to your machine accounts too, not just humans. A CI token that can deploy to production is an account, and it deserves the same scrutiny as a person: scope it to exactly what the pipeline needs, prefer short-lived tokens (GitHub's OIDC-based cloud auth beats a long-lived static key sitting in secrets), and rotate it on a schedule. Service accounts are the credentials attackers love most, because they're powerful, long-lived, and nobody watches them.
This one isn't about a hacker at all, and that's the point. The thing most likely to actually end a small company isn't exfiltration — it's loss. A dropped production database, a bad migration at 2am, a ransomware event, an account you got locked out of because MFA was on a phone that fell in the sea. Data you can't restore is data you've already lost.
The restore drill also surfaces the gaps you'd never find otherwise: a bucket that was never actually included, a schema that changed since the backup format was written, a restore that technically works but takes eleven hours. Better to learn all of that on a calm Tuesday than during the incident.
Security maturity isn't doing everything. It's deciding what you won't do and being honest about why. The difference between an accepted risk and negligence is one thing: an accepted risk is written down, with a name attached and a trigger for revisiting it. Negligence is the same decision made silently and then forgotten.
I keep a plain markdown RISKS.md in the repo. It isn't fancy. Each entry is the risk, why we're accepting it right now, and what would make us change our minds.
## Accepted risks (reviewed quarterly)- No SOC 2 / formal compliance. Why: no enterprise client requires it yet. Cost > benefit. Revisit when: a deal > $X needs it, or we hit 10 people.- Single cloud provider, no multi-region failover. Why: our uptime needs don't justify the cost/complexity. Revisit when: an SLA commits us to it.- Founders share one admin-tier account for billing. Why: MFA is on; blast radius is billing only. Revisit when: finance headcount exists.
Writing it down does two things. It stops the same anxious debate from resurfacing every month — someone gets nervous, points at the file, sees we already decided, and we move on. And it gives you an honest answer when a client's questionnaire asks. "We assessed that and accepted it, here's our reasoning and our trigger to revisit" is a far stronger position than pretending you have a control you don't, or scrambling to fake one over a weekend.
This risk register is also, quietly, the seed of real compliance later. When you eventually do pursue SOC 2, an auditor wants exactly this: evidence that you identified risks and made deliberate decisions about them. A team that's been keeping an honest RISKS.md for two years is dramatically closer to certifiable than one that starts from nothing.
Some of the most-recommended security work is, at three people, pure cost with almost no risk reduction. I skip these until something specific changes, and I don't feel bad about it.
Summer2026! becomes Autumn2026! becomes Winter2026!). A strong unique password in a manager plus MFA beats rotation every time. Rotate on evidence of compromise, not on a calendar.None of this is "never." It's "not yet, and here's the specific trigger." Same discipline as the risk register — the decision is written down, not defaulted into.
You can get most of the value in the list above in a single focused afternoon. Here's the order I'd do it in, and each step is genuinely small:
gitleaks as a pre-commit hook and a CI job. Run it once over full history and rotate anything it finds (45 min).if true. Add emulator tests for the two or three most sensitive collections (60 min).RISKS.md and write down the three biggest things you're consciously not doing (15 min).That's under four hours, and it moves you from "one bad click away from disaster" to "genuinely hard to kill by accident." No consultant, no five-figure spend, no audit. The first four items alone cover the top of the threat list.
This list is right for now. The whole point is that it changes as you grow, and the trigger to revisit is headcount plus who you're selling to. Getting this stage transition wrong in either direction is where teams hurt themselves.
Around ten people, a few things flip from theater to necessary:
The mistake, in both directions, is treating security as a fixed checklist you inherit. At three people, most of the enterprise list is negative ROI, and skipping it deliberately is the correct engineering call. At fifty, skipping the same items is negligence. The skill is knowing which stage you're in and moving the line on purpose — reviewing it every quarter, not letting it drift while you're heads-down shipping.
RISKS.md with the risk, the reason, and the trigger to revisit — it also becomes your head start on real compliance later.