Cybersecurity by Design: Embedding Zero-Trust into Your Product Roadmap
How to bake verification into a product before a breach forces you to

Contents
<p>The most expensive security review I ever sat through happened the week before launch. A pen-tester found that any authenticated user could read any other user’s records by changing one integer in a URL — the classic IDOR — and the fix touched forty endpoints because authorisation had been assumed, never checked. We shipped two weeks late and bolted on a permission layer that should have been a load-bearing wall from day one. That is the lesson behind “zero-trust by design”: the cheap moment to decide that nothing is trusted by default is <em>before</em> you’ve written the code that trusts everything.</p>
<p>Zero-trust gets sold as a product you buy — a vendor box, a magic gateway. It isn’t. It’s a design property: every request is authenticated and authorised on its own merits, regardless of where it came from. The interesting question isn’t “what is zero-trust” (you already half-know), it’s “how do you get it into a roadmap without grinding delivery to a halt?” That’s what this is about, written from the scars.</p>
<h2 id="why-perimeters-stopped-working">Why perimeters stopped working</h2><div class="ad-unit ad-in-article" aria-label="Advertisement">
<span class="ad-label">Advertisement</span>
<ins class="adsbygoogle" style="display:block;text-align:center"
data-ad-client="ca-pub-3726833845844946"
data-ad-slot="3291553914"
data-ad-format="auto"
data-full-width-responsive="true"></ins>
<script>(adsbygoogle = window.adsbygoogle || []).push({});</script>
</div>
<p>The old model was a castle: a hard network perimeter, a soft trusted interior. Get inside the VPN and you were, implicitly, trusted. That assumption was always shaky and is now actively dangerous, because the perimeter dissolved. Your services run across cloud regions, your laptops live on café Wi-Fi, your CI runners are ephemeral VMs in someone else’s data centre, and half your “internal” traffic is actually SaaS APIs.</p>
<p>Google formalised the alternative with <strong>BeyondCorp</strong> in a series of papers starting in 2014, after the <strong>Operation Aurora</strong> intrusions in 2009–2010 made the cost of a soft interior brutally concrete. The principle: trust is derived from identity and device posture evaluated <em>per request</em>, not from network location. The US federal government later codified the same idea — NIST published <strong>SP 800-207</strong> in August 2020, and the OMB memo <strong>M-22-09</strong> (January 2022) set a federal zero-trust deadline. The point of citing these isn’t to wave standards around; it’s that the model has been stress-tested at the scale of nation-state adversaries, and the conclusions are consistent: stop trusting the network.</p>
<p>The practical translation for a product team: a request arriving from <code>10.0.0.0/8</code> gets exactly the same scrutiny as one from the public internet. No endpoint is “safe because it’s internal.”</p>
<h2 id="designing-it-in-where-the-decisions-actually-live">Designing it in: where the decisions actually live</h2>
<p>Zero-trust by design lives in three places, and if you skip any of them you’ve got security theatre.</p>
<p><strong>1. Identity on every hop.</strong> Service-to-service calls authenticate, not just user-facing ones. The clean way to do this today is short-lived, verifiable tokens — OIDC for users, mTLS or workload identity (SPIFFE/SPIFFE-style SVIDs) between services. The anti-pattern is a shared static API key in an environment variable that every service trusts forever.</p>
<p><strong>2. Authorisation as a first-class component.</strong> This is the bit teams skip, and it’s the bit that bites. Authentication answers “who are you”; authorisation answers “are you allowed to touch <em>this specific resource</em>.” Centralise it. A policy engine like Open Policy Agent lets you express rules as code and test them, instead of scattering <code>if user.role == "admin"</code> across the codebase. Here’s a Rego policy denying cross-tenant reads — the exact class of bug that cost me two weeks:</p>
<div class="highlight"><div class="chroma">
<table class="lntable"><tr><td class="lntd">
<pre tabindex="0" class="chroma"><code><span class="lnt"> 1
</span><span class="lnt"> 2
</span><span class="lnt"> 3
</span><span class="lnt"> 4
</span><span class="lnt"> 5
</span><span class="lnt"> 6
</span><span class="lnt"> 7
</span><span class="lnt"> 8
</span><span class="lnt"> 9
</span><span class="lnt">10
</span><span class="lnt">11
</span></code></pre></td>
<td class="lntd">
<pre tabindex="0" class="chroma"><code class="language-rego" data-lang="rego"><span class="line"><span class="cl"><span class="kd">package</span><span class="w"> </span><span class="nx">authz</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="c"># Default deny. Everything must be explicitly allowed.</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="kd">default</span><span class="w"> </span><span class="nx">allow</span><span class="w"> </span><span class="o">:=</span><span class="w"> </span><span class="kc">false</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="c"># A user may read a record only within their own tenant.</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="n">allow</span><span class="w"> </span><span class="kd">if</span><span class="w"> </span><span class="p">{</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w"> </span><span class="nx">input</span><span class="o">.</span><span class="nx">action</span><span class="w"> </span><span class="o">==</span><span class="w"> </span><span class="s2">"read"</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w"> </span><span class="nx">input</span><span class="o">.</span><span class="nx">resource</span><span class="o">.</span><span class="nx">type</span><span class="w"> </span><span class="o">==</span><span class="w"> </span><span class="s2">"record"</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w"> </span><span class="nx">input</span><span class="o">.</span><span class="nx">subject</span><span class="o">.</span><span class="nx">tenant</span><span class="w"> </span><span class="o">==</span><span class="w"> </span><span class="nx">input</span><span class="o">.</span><span class="nx">resource</span><span class="o">.</span><span class="nx">tenant</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="p">}</span><span class="w">
</span></span></span></code></pre></td></tr></table>
</div>
</div><p>Default-deny is the whole game. If a new endpoint forgets to declare a policy, it fails closed, not open.</p>
<p><strong>3. Minimal, expiring access.</strong> Grant the least privilege that works, and make grants expire. Standing admin access is a liability that grows quietly; time-boxed, audited elevation turns “who has the keys” from an archaeology project into a query.</p>
<h2 id="wiring-it-into-the-pipeline-without-grinding-to-a-halt">Wiring it into the pipeline without grinding to a halt</h2><div class="ad-unit ad-in-article" aria-label="Advertisement">
<span class="ad-label">Advertisement</span>
<ins class="adsbygoogle" style="display:block;text-align:center"
data-ad-client="ca-pub-3726833845844946"
data-ad-slot="3291553914"
data-ad-format="auto"
data-full-width-responsive="true"></ins>
<script>(adsbygoogle = window.adsbygoogle || []).push({});</script>
</div>
<p>The legitimate objection to all this is speed: security gates that block delivery get routed around, and a control everyone bypasses is worse than no control because it lies to you. So the gates have to be fast, automated, and fail for good reasons. A minimal CI security stage:</p>
<div class="highlight"><div class="chroma">
<table class="lntable"><tr><td class="lntd">
<pre tabindex="0" class="chroma"><code><span class="lnt"> 1
</span><span class="lnt"> 2
</span><span class="lnt"> 3
</span><span class="lnt"> 4
</span><span class="lnt"> 5
</span><span class="lnt"> 6
</span><span class="lnt"> 7
</span><span class="lnt"> 8
</span><span class="lnt"> 9
</span><span class="lnt">10
</span><span class="lnt">11
</span><span class="lnt">12
</span><span class="lnt">13
</span></code></pre></td>
<td class="lntd">
<pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">security</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w"> </span><span class="nt">stage</span><span class="p">:</span><span class="w"> </span><span class="l">test</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w"> </span><span class="nt">script</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w"> </span><span class="c"># Pinned dependencies, audited for known CVEs</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w"> </span>- <span class="l">npm audit --audit-level=high</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w"> </span><span class="c"># Static analysis for injection, IDOR-ish patterns, secrets</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w"> </span>- <span class="l">semgrep --config auto --error</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w"> </span><span class="c"># No credentials committed, ever</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w"> </span>- <span class="l">gitleaks detect --no-banner --redact</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w"> </span><span class="c"># Policy unit tests — the authz rules above are tested like any code</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w"> </span>- <span class="l">opa test policy/ -v</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w"> </span><span class="nt">rules</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w"> </span>- <span class="nt">if</span><span class="p">:</span><span class="w"> </span><span class="l">$CI_PIPELINE_SOURCE == "merge_request_event"</span><span class="w">
</span></span></span></code></pre></td></tr></table>
</div>
</div><p>Two principles make this survivable. First, <strong>tune the noise ruthlessly</strong> — a scanner that cries wolf on every build trains the team to ignore it; suppress the false positives explicitly and in version control so the suppression is reviewable. Second, <strong>the policy tests run with the unit tests</strong>, so a developer who breaks an authorisation rule finds out in ninety seconds, not in a pen-test report. The same discipline that makes a <a href="/story/from-zero-to-ssh-hero-securely-hardening-a-linux-server-in-2025/">hardened SSH setup actually stick</a> applies here: a control is only real if it’s enforced automatically and survives the next tired Friday deploy.</p>
<h2 id="identity-mfa-and-the-trust-signal">Identity, MFA, and the trust signal</h2>
<p>User auth is the front door, so put real locks on it. Phishing-resistant MFA — WebAuthn/passkeys, not SMS codes — from day one, because retrofitting MFA onto an established user base is a migration project nobody enjoys. SMS OTP is better than nothing but defeated by SIM-swapping; treat it as a fallback, not the goal.</p>
<p>The subtler move is treating device and context as <em>signals</em>, not gates. A login from a managed device on a known network is low-risk; the same credentials from a new device in a new country at 3am warrant a step-up challenge. You don’t need a six-figure platform for this — even a coarse risk score that triggers re-authentication catches the lazy end of credential-stuffing. If you’re already running a homelab-grade mesh like <a href="/story/tailscale-a-zero-config-mesh-vpn/">Tailscale for zero-config access</a>, you’ve felt the appeal of identity-bound networking; zero-trust at product scale is the same instinct, generalised and made auditable.</p>
<h2 id="what-it-costs-the-honest-part">What it costs (the honest part)</h2>
<p>I won’t pretend this is free.</p>
<ul>
<li><strong>Cultural buy-in is the hard currency.</strong> Default-deny means developers occasionally get blocked by their own policies, and the first few times that feels like the security team being obstructive. It isn’t, but it feels that way, and you manage it with fast feedback and good error messages, not lectures.</li>
<li><strong>There’s real upfront work</strong> — a policy engine, identity plumbing, MFA enrolment flows. It’s front-loaded; the payoff is amortised over every breach you <em>don’t</em> have.</li>
<li><strong>Over-rigid process is its own failure mode.</strong> If every change needs three approvals, people stop making changes, or worse, make them out of band. The goal is automated guardrails, not human checkpoints.</li>
</ul>
<p>There’s also a failure mode I’ve watched first-hand: treating zero-trust as a checkbox. A team enables mTLS between services, declares victory, and leaves authorisation as <code>default allow</code> with a handful of explicit denies. They’ve authenticated everything and authorised nothing — they verified <em>who</em> is calling while never checking <em>whether the call is permitted</em>. That’s the IDOR story all over again, dressed up in certificates.</p>
<h2 id="troubleshooting-the-rollout">Troubleshooting the rollout</h2>
<p>A few concrete things that go wrong, and what to do:</p>
<ul>
<li><strong>“Everything broke when we turned on default-deny.”</strong> Expected, and good — it means you found the implicit trust. Roll out in <em>log mode</em> first: evaluate policies, log what <em>would</em> be denied, change nothing. Watch the logs for a week, fix the legitimate access that you forgot to declare, <em>then</em> flip to enforce.</li>
<li><strong>Service-to-service calls fail after adding mTLS.</strong> Almost always clock skew (certs are time-sensitive — fix NTP) or a missing SAN on a certificate. Check the cert’s subject alternative names match the service identity the caller expects.</li>
<li><strong>MFA enrolment cratered support tickets.</strong> You launched mandatory MFA with no grace period and no fallback. Stage it: opt-in, then nudge, then enforce, with a clearly documented account-recovery path that isn’t itself a backdoor.</li>
<li><strong>The scanner blocks an urgent hotfix.</strong> Have a break-glass path that’s <em>logged and reviewed after the fact</em>, not a flag anyone can set silently. The audit trail is what keeps the exception honest.</li>
</ul>
<h2 id="a-worked-example-retrofitting-the-idor-i-opened-with">A worked example: retrofitting the IDOR I opened with</h2>
<p>Concretely, here’s how the same default-deny discipline would have killed the bug that cost me two weeks. The original endpoint did roughly this: authenticate the request, look up the record by the ID in the URL, return it. The only check was “are you logged in” — authentication without authorisation. Any logged-in user could read any record by guessing IDs.</p>
<p>The zero-trust-by-design version doesn’t trust the fact that the caller is authenticated. Every read goes through the policy engine, which is handed the subject, the action, and the <em>resource being touched</em> — including its tenant. The Rego rule from earlier returns <code>allow := false</code> unless the subject’s tenant matches the resource’s tenant, and because the default is deny, an endpoint that <em>forgets</em> to consult the policy returns nothing useful rather than leaking. The win isn’t the specific rule; it’s that the architecture made the secure path the path of least resistance. A developer adding a new endpoint has to actively opt <em>out</em> of the check to create a hole, instead of having to remember to opt <em>in</em>. That inversion — secure-by-default rather than secure-if-you-remember — is the entire point of designing it in rather than bolting it on.</p>
<p>The cost of doing this on day one was a policy engine and a habit. The cost of doing it the week before launch was forty endpoints and a delayed release. Same fix, two orders of magnitude apart in price, and the only variable was <em>when</em> the decision got made.</p>
<p>There’s a second-order benefit worth naming. Once authorisation lives in one tested place rather than scattered through forty handlers, you can <em>reason</em> about it. You can ask “who can read this resource type” and answer it by reading one policy file, not by grepping the codebase and hoping you found every branch. Audits stop being archaeology. New engineers learn the access model from the rules instead of from folklore. And when a regulator or a customer’s security team asks you to prove that tenant isolation holds, you point at a file and its test suite rather than swearing on the codebase. Centralised, tested authorisation isn’t just safer — it’s the only version of “we enforce tenant isolation” that you can actually demonstrate is true.</p>
<h2 id="is-it-worth-it-and-who-is-this-for">Is it worth it, and who is this for?</h2>
<p>If you’re a two-person prototype chasing product-market fit, full zero-trust is premature; you’ll move faster with sane basics — MFA, no secrets in git, least-privilege cloud roles — and revisit it when you have something worth attacking. Don’t gold-plate a product nobody uses yet.</p>
<p>But the moment you’re holding other people’s data, handling money, or selling to anyone with a procurement checklist, designing verification in from the start is dramatically cheaper than the retrofit I lived through. The whole argument compresses to one line: <em>the expensive time to discover you trusted everything is during an incident.</em> Decide that nothing is trusted by default while the decision is still a few lines of policy and a CI stage — not forty endpoints and a delayed launch.</p>
Advertisement
Related Content
Advertisement




