Security, Misconceptions, and Hybrid Architectures
This is Part 5 of the “Where Does Business Logic Live?” series. We have made the database case, the application case, and the performance and operations comparison. Three things remain before we can build a decision framework: how security actually differs between the approaches, the four myths this debate keeps recycling, and what a sensible hybrid looks like in practice.
Security is where I see the most confident wrong statements from both camps, so let’s be precise about it.

Permissions: The Procedure Boundary vs Direct Table Access
The most consequential security difference is the one from Part 2, and it is worth restating as a security point rather than a performance one.
When an application connects with a principal that has EXECUTE on a set of procedures and no direct table permissions, the database itself constrains what the application can do. The set of possible operations is exactly the set of procedures you wrote. There is no “the app has a bug and deleted the customer table,” because the app’s login has no DELETE on that table. Ownership chaining makes this practical: the caller needs rights on the procedure, not on the objects it touches.[1]
The common alternative – an application that logs in with broad SELECT/INSERT/UPDATE/DELETE on the tables and composes its own SQL – is a larger attack surface by construction. Anything that can run as that principal, including injected SQL or a compromised app server, can do anything that principal can do, which is a lot.
This is a real database-side security advantage. It is not the whole security story, because the application tier owns categories of security the database cannot see, which we will get to.
SQL Injection: A Property of Dynamic SQL, Not of a Tier
SQL injection is the single most misattributed risk in this debate, so let’s be exact. Injection happens when untrusted input is concatenated into a SQL string that then gets executed.[2] It is a consequence of building SQL by string concatenation – and you can do that, or avoid it, in either tier.
Parameterized queries from the application are safe. Stored procedures called with parameters are safe. A stored procedure that builds dynamic SQL by concatenating its parameters into a string and EXEC-ing it is not safe – it is just as injectable as a careless application. And an application that uses parameterized commands is not made vulnerable by talking directly to tables.
|
1 2 3 4 5 6 7 |
/* Injectable - concatenates input into the statement, even inside a proc */ SET @sql = N'SELECT * FROM dbo.customer WHERE name = ''' + @name + N''''; EXEC (@sql); /* Safe - parameterized; @name can never change the statement's structure */ SET @sql = N'SELECT [id], [name] FROM [dbo].[customer] WHERE [name] = @name'; EXEC [sys].[sp_executesql] @sql, N'@name nvarchar(128)', @name = @name; |
So “stored procedures prevent SQL injection” is only true to the extent that they encourage parameterization and avoid dynamic string-building – which they do, as a tendency, but not as a guarantee. The accurate statement is: parameterize your inputs, never concatenate them, wherever the SQL is written. The procedure boundary helps with injection mainly through the permissions point above – it shrinks what a successful injection can reach – not through some inherent immunity.
What the Application Tier Owns: Identity, Authorization, and the Edge
Now the other side, because the database is not the right place for several security concerns and DBAs sometimes forget this.
Authentication, authorization, session management, OAuth and OIDC flows, token validation, rate limiting, request throttling, bot detection, and input validation at the API boundary are all application-tier and gateway concerns. The database has no concept of “this JWT is expired” or “this client has exceeded its rate limit.” Identity-aware, request-level security lives in the application and the API gateway, and that is correct. A modern system’s security posture is mostly built up there – the database’s permission model is one important layer underneath it, not a replacement for it.
The healthy way to see it: the application tier secures who is asking and whether they are allowed to ask; the database secures what can be done to the data once a request gets through. Defense in depth wants both.
Row-Level Security: Per-Row Access the Database Enforces
One capability the database has that the application tier cannot fully replicate is row-level security – filtering which rows a principal can see or change, enforced by the engine on every query regardless of how the query arrives.[3]
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
CREATE FUNCTION [sec].[fn_tenant_predicate](@tenant_id int) RETURNS TABLE WITH SCHEMABINDING AS RETURN SELECT 1 AS [allowed] WHERE @tenant_id = CONVERT(int, SESSION_CONTEXT(N'tenant_id')); CREATE SECURITY POLICY [sec].[tenant_isolation] ADD FILTER PREDICATE [sec].[fn_tenant_predicate]([tenant_id]) ON [dbo].[invoice] WITH (STATE = ON); |
The value here is that the rule “a tenant can only see its own rows” is enforced by the database for every query against that table – the reporting tool, the second service, the ad-hoc query – not just the application that remembered to add a WHERE tenant_id = ?. For multi-tenant systems and strict data-isolation requirements, enforcing this at the data layer is far more robust than trusting every caller to filter correctly. You can enforce tenant filtering in the application too, and many do; the difference is that application-tier filtering protects you only as long as every code path remembers to apply it, while the database predicate protects you even when one does not.
Dynamic Data Masking: Useful, With an Honest Caveat
SQL Server’s Dynamic Data Masking can obscure sensitive column values – showing XXXX-XXXX-XXXX-1234 instead of a full card number – for principals without the UNMASK permission, without changing the stored data.[4]
|
1 2 3 4 5 |
ALTER TABLE [dbo].[customer] ALTER COLUMN [email] ADD MASKED WITH (FUNCTION = 'email()'); ALTER TABLE [dbo].[payment] ALTER COLUMN [card_number] ADD MASKED WITH (FUNCTION = 'partial(0, "XXXX-XXXX-XXXX-", 4)'); |
The honest caveat, which Microsoft states plainly and which I will not bury: dynamic data masking is a presentation-layer convenience, not a strong isolation boundary. A determined user with query access can often infer or extract masked values through inference attacks – filtering, casting, range queries – so it should not be your only control for genuinely sensitive data. It is a good fit for reducing casual exposure (a support rep who should not routinely see full card numbers); it is not a substitute for real access control, encryption, or simply not granting query access to the data in the first place. I include it because it is frequently over-sold as a security boundary, and being accurate about its limits is part of giving honest architectural advice.
Regulatory Compliance
Compliance regimes – PCI DSS for cards, HIPAA for health data, GDPR and similar for personal data – care about access control, auditability, data minimization, and demonstrable controls. Both tiers contribute, and the split is fairly clean.
The database contributes the controls that are strongest when anchored to the data: complete audit trails that capture every change regardless of source (Part 4), least-privilege access through the procedure boundary, row-level security for need-to-know access, encryption at rest, and keeping regulated data from traveling to tiers that would then need their own controls. The application contributes consent management, identity, purpose-of-use enforcement, data-subject-access and erasure workflows, and the audit of who requested what and why. Neither tier satisfies a serious compliance requirement alone. The pattern that holds up under an audit is “controls close to the data, plus identity and workflow controls in the application,” which – you will notice – is just the hybrid we keep arriving at.
Four Myths Worth Retiring
Every time this debate flares up, the same four claims get repeated as if they were settled. None of them survives contact with the evidence in this series.
Myth 1: “Stored procedures are always faster.”
They are not always faster. Part 4 showed that plan-related performance is a property of how the SQL is parameterized, not of which tier emitted it, and that parameter sniffing bites procedures too. A badly written procedure with a bad plan loses to a well-written parameterized application query every time. Procedures do have real, specific performance advantages – set-based work next to the data, fewer round trips, plan reuse – but “always faster” is a slogan, not a measurement. Where they win, they win for reasons you can name and verify.
Myth 2: “Business logic never belongs in the database.”
This one usually comes from teams that have been burned by a stored-procedure monolith, and the overcorrection is understandable but wrong. Data integrity belongs in the database – it is the only tier that sees every write. Large set-based manipulation belongs where the data is. The transactional core that must stay correct no matter which application writes belongs at the data layer. The long-running discussions on how much logic the database should implement do not conclude “none”; they conclude “it depends, and here are the factors.”[5] “Never in the database” throws away the database’s genuine strengths to avoid its genuine weaknesses, when the actual job is to use one and not the other.
Myth 3: “Modern architectures eliminate the need for database expertise.”
This is the most expensive myth, because teams act on it by not hiring or consulting database people – and then meet the consequences in production. Microservices, serverless, and ORMs do not remove the database; they put more layers between the developer and it, which makes deep database problems harder to see, not absent. The biggest engineering organizations that lean hardest on application-tier logic – GitHub, Shopify, Stack Overflow – are also organizations that invest heavily in database expertise, because at scale the database is exactly where the hard problems concentrate. Erik Darling’s entire body of work is, in effect, a running demonstration that abstraction is not magic and the people who treat it as magic end up calling someone who understands the engine. Moving logic out of the database moves neither the data nor the consequences out of the database.
Myth 4: “ORMs solve all data access problems.”
ORMs solve a real and important problem – the tedious, error-prone mapping between objects and relational rows – and they are genuinely productive tools. What they do not do is free you from understanding the SQL they generate. The N+1 problem, unparameterized IN lists, SELECT * over-fetching, and incomprehensible machine-generated queries are not rare edge cases; they are the routine failure modes a DBA sees from ORM-heavy applications.[6] The mature position, which is also Erik Darling’s and Brent Ozar’s, is that ORMs are fine as long as someone on the team can read the generated SQL and knows when to drop to hand-written queries or a stored procedure. An ORM is a productivity tool, not a database-knowledge replacement.
Hybrid Architectures: Where Everyone Actually Ends Up
After four posts of tradeoffs, here is the part that matters most, because it is where the sophisticated teams converge regardless of which camp they started in. Almost nobody who operates at scale runs a pure “all in the database” or “all in the application” system. They run a hybrid, and the hybrids look remarkably alike.
The placement that holds up:
| Logic type | Where it lives | Why |
|---|---|---|
| Data integrity (keys, constraints, uniqueness, not-null) | Database | Only tier that sees every write; last line of defense |
| Set-based data manipulation over large volumes | Database | Data gravity; one set operation beats row-by-row |
| Transactional core that must stay correct across all writers | Database | Short, local transactions; engine-controlled locks |
| Per-row access isolation | Database (row-level security) | Enforced for every caller, not just the careful one |
| Sensitive-data controls, complete audit | Database | Anchored to the data; minimizes where regulated data travels |
| Domain modeling and complex branching rules | Application service | Better expressed in a general-purpose language |
| Cross-system workflow and external integration | Application service | Database should not orchestrate external calls |
| Identity, authorization, rate limiting | Application / API gateway | Request-level, identity-aware concerns the database cannot see |
| Presentation and fast-feedback validation | Client / SPA | Closest to the user; never the authoritative check |
A concrete example ties it together. Take an order-management system:
- The database holds the constraints (an order line must reference a real product; quantity is non-negative), a stored procedure for the transactional core of placing an order (the stock check, insert, and inventory decrement in one short transaction), row-level security so each tenant sees only its own orders, and temporal tables capturing a complete audit trail.
- The application service owns the checkout workflow – calling the payment provider with an idempotency key, scheduling shipment, sending notifications, and compensating if the charge fails – and the domain model for the branching business rules around pricing, promotions, and approvals.
- The API gateway and service own authentication and authorization; the SPA does instant client-side validation for user experience, knowing the authoritative checks live deeper.
Every piece is where it is for a reason you can articulate. That is the difference between a hybrid and a mess: a hybrid is a set of deliberate placement decisions; a mess is the residue of whoever won each argument. Shopify’s evolution to a modular monolith – keeping the code together but enforcing real boundaries between components – is the same instinct applied to application architecture: not “monolith versus microservices,” but “explicit boundaries, drawn on purpose.”[7]
In Part 6, the finale, I turn this into an actual decision framework – a checklist you can run against a specific piece of logic to decide where it belongs – and close the series by looking at why these arguments turn ideological, and how to keep yours evidence-based.
What does your hybrid look like, and where did you draw the boundaries? You can find me on Bluesky and LinkedIn.
References and Footnotes
- “Stored Procedures (Database Engine),” Microsoft Learn – procedures as a security mechanism; execute permission without direct permissions on underlying objects. learn.microsoft.com. ↩ back
- “SQL Injection,” OWASP – injection results from inserting untrusted input into a SQL query; mitigated by parameterization. owasp.org. ↩ back
- “Row-Level Security,” Microsoft Learn – predicate-based filtering enforced by the engine on every access to the table. learn.microsoft.com. ↩ back
- “Dynamic Data Masking,” Microsoft Learn – column-level obfuscation for non-privileged principals; documented as a presentation-layer feature, not a strict isolation boundary. learn.microsoft.com. ↩ back
- “How much business logic should the database implement?” – the long-running discussion that concludes “it depends,” not “none.” softwareengineering.stackexchange.com. ↩ back
- Erik Darling, “Signs It’s Time To Switch From ORMs To Stored Procedures In SQL Server Development” – the routine failure modes of ORM-generated SQL. erikdarling.com. ↩ back
- “Deconstructing the Monolith: Designing Software that Maximizes Developer Productivity,” Shopify Engineering – explicit, deliberately drawn boundaries between components. shopify.engineering. ↩ back
Next up: A Decision Framework (and When to Stop Arguing) – a practical checklist for placing any piece of logic, real-world examples at scale, and why these debates turn ideological.
Part of the “Where Does Business Logic Live?” series – Part 1, Part 2, Part 3, Part 4.