“We’ll add security later” is one of the most expensive sentences in software. Security bolted on at the end is slower, weaker, and full of gaps that were designed in months earlier. Secure by design means the opposite: making security a property of how the software is built, from the first architecture decision. Here is what that actually looks like in practice — not as a slogan, but as concrete engineering.
It Starts Before the First Line of Code
Secure design begins at architecture, with a simple question asked deliberately: what could go wrong here, and who might make it go wrong? That is threat modeling — walking the data flows and trust boundaries of a system and naming the ways they could be abused before any of it is built. An hour of this at design time routinely prevents a class of vulnerabilities that would take weeks to find and fix later. It is the highest-leverage security activity there is, and it costs nothing but attention.
Secure Defaults, Not Secure Options
The difference between a system that is secure and one that can be configured to be secure is enormous. Secure by design means the safe path is the default path: encryption on by default, least privilege by default, deny by default, sessions that expire, inputs that are validated unless explicitly marked safe. If a developer or an administrator has to remember to turn security on, sooner or later someone won’t — and that omission becomes the breach. Make the secure choice the one that happens automatically.
The Principles That Do the Work
- Least privilege. Every component, service, and user gets the minimum access it needs and nothing more. When something is compromised — and eventually something is — least privilege is what limits the damage.
- Defense in depth. No single control is trusted to be perfect. Layers — validation, authentication, authorization, monitoring — so that one failure is caught by the next.
- Validate at the boundary. Treat everything crossing a trust boundary as hostile until proven otherwise: user input, API responses, file uploads, and the arguments an AI passes to a tool.
- Fail closed. When something breaks or is uncertain, deny rather than allow. An error should never become an open door.
Design Reviews Beat Penetration Tests
Both matter, but a security review at design time finds problems a penetration test at the end never can — because by then they are load-bearing. A pen test tells you a wall has a crack; a design review tells you the wall is in the wrong place before you build it. The teams that ship secure software review architecture and code for security continuously, not once, right before launch, as a formality.
Security Is a Way of Building, Not a Phase
The core idea is simple and it is not a checklist item: secure software comes from teams that think about failure and abuse as they design, choose safe defaults, and limit trust everywhere. It is cheaper than the alternative, and it is the only approach that actually scales.
BulletproofSoft builds security into software from the first design decision — threat modeling, secure architecture, and code review as part of engineering, not an afterthought. Tell us what you’re building and we will help you design it to withstand the real world.
What a Threat Modeling Session Actually Produces
The value of the session is in its outputs, and they are specific enough to be reviewed later by someone who was not in the room. The diagram uses four element types: external entities, processes, data stores, and the flows between them. That vocabulary is what makes a boundary visible instead of implied. The threats come from a prompt list rather than from whoever in the room is most inventive, and STRIDE is the common one: spoofing, tampering, repudiation, information disclosure, denial of service, elevation of privilege. Applying it element by element rather than to the system as a whole is what surfaces the unglamorous findings, like a queue that any service in the cluster can write to.
Every threat named gets a disposition before the session closes: mitigated by a specific control, eliminated by removing the feature or the flow, transferred to another party with the contract that makes that real, or accepted with a named owner and a date to revisit. Accepted risk that was written down is a decision somebody made; accepted risk that was never written down is an omission nobody can point to.
Scope one flow at a time, starting with the flow carrying the most sensitive data class or crossing the most boundaries. The trigger to run it again is structural, not calendar-based: a new external integration, a new tenant model, a new administrative path, a change to how authentication or authorization works, or a new data class such as payment or health data entering a system that did not previously hold one.
- A current data flow diagram with trust boundaries marked, kept in the repository beside the code rather than in a wiki that drifts away from what is deployed.
- One row per threat: the element it applies to, the abuse, the disposition, the owner.
- The assumptions the design depends on, written as statements that can later be checked, such as "the ingress terminates TLS and forwards the client certificate fingerprint" or "the queue is writable only by the order service".
- The threats deliberately ruled out of scope, so a later reader can tell what was considered and set aside from what was never thought of.
Trust Boundaries Sit Where Trust Changes, Not Where the Firewall Is
A trust boundary is any point where a request or a value moves from one level of trust to another, and that often has nothing to do with network topology. Two services in the same VPC, owned by different teams and released on different schedules, are on opposite sides of one. So is the line between an application and its own database when a reporting job also writes to that database. So is the CI runner that produces the artifact you deploy. The test that finds them: ask who can influence the value and who can observe the result, and if either answer includes someone outside the component's owner, there is a boundary there.
Order of operations at the boundary decides whether validation works at all. Decode first, then validate, then use. Validation performed before canonicalization inspects a string the runtime will never actually see, which is how percent-encoded traversal such as %2e%2e%2f, overlong UTF-8 sequences, and Unicode normalization tricks get past filters that looked correct. Check against an allowlist of what is acceptable rather than a denylist of what is known bad, and convert to a typed representation at the boundary so code further in cannot reinterpret the raw string its own way.
Validation and encoding are different jobs and neither substitutes for the other. Validation decides whether the input is acceptable at all. Encoding decides how a value is safely represented in one specific sink, and the correct encoding differs per sink: bound parameters for SQL values, quoted identifiers for SQL identifiers, which bound parameters cannot carry; context-aware escaping for HTML text, attributes, URLs, and inline script, which are four different grammars; an argument array rather than a shell string for process execution. A value that passed validation is still dangerous in a sink whose grammar it was never encoded for.
Boundaries that get missed are usually the ones with no network hop to make them obvious:
- Deserialization of a message taken off a queue, where the payload is trusted because it came from inside the system and the queue's write permissions were never examined.
- Server-side fetches of a user-supplied URL, where the request originates inside the network and can reach internal admin ports and the cloud instance metadata service at 169.254.169.254.
- File uploads, where a parsing library reads format metadata before any of your own code sees the file.
- Webhooks, where the real boundary is the signature check and the replay window, not the TLS connection.
- Template rendering with user-controlled template text, where the input is executed by the template engine rather than displayed by it.
- The build pipeline, where the artifact that reaches production is produced by a runner executing configuration that lives in the repository.
Fail Closed Has an Availability Cost That Has to Be Designed For
Denying on uncertainty makes the availability of the authorization decision the availability of the protected function. If the policy service, the identity provider, or the session store is unreachable, the feature is unavailable, and at scale that reads as an outage. The cost is real and it belongs in the design rather than in an incident review.
The engineering work is to shrink the set of requests whose answer comes back unknown. Verify signed tokens locally against a cached JWKS instead of calling the issuer on every request. Distribute the policy bundle to the enforcement point so evaluation happens in process. Keep token lifetimes short so a local decision cannot be stale for long, and give every cache an explicit staleness bound plus a defined behavior for what happens once that bound is exceeded. A decision that can be made locally is not affected by the dependency being down.
Where equipment has a defined safe state, the placement of the control is itself a design decision. Refusal belongs at the perimeter, where it stops new sessions and new commands and the effect is contained, rather than at a point inside a control loop where enforcement would leave equipment in a condition the safety documentation does not describe. That is a choice about where the enforcement point sits, made at design time, and it is why the security design gets drawn against the safety case rather than separately from it.
Two denials that look identical to the user need to look different in the logs. A request refused because policy said no is a working system. A request refused because the check could not run is a dependency failure wearing the same response code. Emitting a distinct reason for each is what lets an on-call engineer tell in seconds which one is happening, and only one of them is an incident.
Least Privilege Requires Somebody to Write Down What "Needed" Means
Nobody argues with the principle. The obstacle is that nobody can enumerate the accesses a service actually uses, so the policy stays a wildcard that no one wants to touch. The practical method is observation: run the component with permissive policy in an environment where every access is logged, collect the calls it makes across a window that includes the batch jobs and the failure paths, then write the policy from the observed set plus the accesses that are planned but not yet exercised. What comes out is a policy a reviewer can actually read and challenge.
Privilege on identities is only half of it. If one service account can reach every tenant's rows, narrowing the API surface has not narrowed the blast radius, and the scoping has to reach the data itself: row-level policies keyed on the tenant, per-tenant keys for anything encrypted at the application layer, or separate stores. The test to apply to any design is to pick a single credential and describe exactly what someone holding it can read and change. If the answer is everything in the database, privilege was managed at the wrong layer.
The exceptions are where this gets decided in practice: migration jobs, support tooling that reads customer records to answer a ticket, and break-glass access during an incident all need more than routine privilege. Give each one a separate identity with its own policy, credentials that expire, and access that produces a record naming the human involved, rather than widening the permanent role until it covers the worst case. A standing permission granted for a one-time need is indistinguishable, a year later, from one that was always required.
Applying This to a System That Already Exists
Most software that needs this is already running, and its first architecture decisions were made by somebody else. The principles do not change, but the order does, because you cannot re-architect on the first pass. Start where one change covers the most flows, which in most systems is the authorization path, since it is the code every request passes through. Consolidating permission checks scattered across handlers into a single enforced path is what makes every later change cheap enough to be worth making.
Inventory before review. Every entry point counts: HTTP routes, queue consumers, scheduled jobs, administrative interfaces, and any endpoint still reachable by an old mobile client that was never withdrawn. So does every data store, and every credential with its owner and its rotation status. Reviewing code you have not inventoried finds the problems in the files somebody chose to show you.
Retrofitting a secure default is a compatibility problem before it is a security problem, because existing clients depend on the permissive behavior and flipping it breaks them. The sequence that works is to implement the secure behavior, enable it for new tenants or a new API version, log every request that would have been refused under it, and use that log to find out who is relying on the old path before moving them. Retiring the old path then needs a version boundary clients can be migrated across, which is a product decision as much as an engineering one.
Frequently Asked Questions
Is secure by design a standard, or something an organization gets certified against?
It is an engineering approach, not a certification. Published frameworks describe the same practices in checkable form: NIST's Secure Software Development Framework, SP 800-218, organizes them into preparing the organization, protecting the software, producing well-secured software, and responding to vulnerabilities, and CISA's secure by design guidance frames the same idea as the manufacturer taking ownership of security outcomes rather than passing them to the customer. Those documents are useful as shared vocabulary and as a structure for evidence. Neither produces a security property on its own, because the property comes from the design decisions the framework only names.
What has to already exist before a threat model is worth running?
Enough of a design to draw: the components, the flows between them, and where data enters and leaves. You also need someone who can answer questions about intended behavior, and a statement of what data the system holds and what obligations attach to it, since that is what decides which threats matter. If the architecture is not settled, model the parts that are and record the rest as assumptions to check later, which is more useful than waiting for a design that has become expensive to change.
Does a design review remove the need for penetration testing?
No. A penetration test reports things a design review has no access to: configuration that has drifted from the design in the environment as deployed, the dependency versions actually running in production rather than the ones in the manifest, and chains where several individually minor weaknesses combine into a usable path. Sequence them so each does its own job, with the review while the design is still on paper and again when the code implementing a boundary lands, and the test run against a build that matches production, including the same TLS settings, the same feature flags, and the same identity provider configuration.
How does any of this apply to features built on AI models?
The decisive question is whose permissions the tool call executes with. If the tools exposed to a model authenticate as one shared service account, then every user of that feature effectively reaches whatever that account can reach, including other tenants' data, regardless of what the prompt or the system instructions say. Carry the end user's identity into the tool call and run the same authorization check that guards the direct API path, so the model's reach is bounded by the caller's reach rather than by its instructions. Log the invocation with that identity attached, because a tool call with no user attribution cannot be investigated after the fact.
How does a design review fit into a team already shipping on short cycles?
It attaches to changes, not to the calendar. Write down which changes trigger a review, such as a new external interface, any change to authentication or authorization code, a new data class, or a new third party that will process customer data, and keep that trigger list in the repository where the change is being made. Findings then have to land in the same tracker as everything else, with the same definition of done, or they accumulate in a document that nobody is accountable for closing.
What does the customer have to provide or decide?
Access to the architecture as it actually is: the repository, the deployment configuration, and a person who can explain what is running where, since deployed reality and documented design differ in most systems. Also a statement of the data classes held and the obligations attached to them, and a scope decision naming which flows are in and which are explicitly out. The decisions that cannot be delegated to engineering are the risk acceptances: which threats get mitigated now, which are accepted, and who accepts them. Without a named person authorized to accept residual risk, the findings have no one to close them.
What does this connect to in an existing delivery pipeline?
The attachment points are the commit, the pull request, and the build: secret scanning before code is committed, static analysis and dependency checks on the pull request, infrastructure-as-code checks on the same trigger, and version-pinned, verified build inputs. What matters more than which scanner runs is the gate policy: which findings block a merge, which are advisory, and how an exception is granted, by whom, and when it expires. A gate that blocks on every finding at every severity gets switched off, and then nothing runs at all. None of it replaces the design decisions, because a scanner can only compare code against patterns somebody already described to it.