Skip to main content
ANVISoftware Solutions
Lesson 22 of 22Advanced20 min

Database Security

By the end of this lesson

Grant least-privilege access and protect data at rest.

Database security answers three questions. Who may connect. What may they do once connected. And what is readable if a copy of the data ends up somewhere it should not be.

This lesson is about building safely, so everything here is a defensive measure. Nothing in it explains how to attack a database, and you do not need that knowledge to protect one.

One idea runs through all three answers: reduce what is possible, rather than relying on nothing going wrong. A component that cannot delete rows will not delete rows through a bug, a mistake or a compromise.

Least privilege is that idea applied to permissions. Every account gets exactly what its job needs and nothing more. It is the highest-value change most applications can make, and it is usually a configuration change rather than a code change.

The practical way to apply it is through roles rather than individual accounts. Grant permissions to a role that describes a job — the ordering application's write access, the reporting tool's read access — then add accounts to the role. When a fourth service needs the same access, it joins the role instead of collecting its own grants that nobody can audit.

An application account that can do its job and nothing else
SQL
-- A login for the application. The password comes from your secret store,
-- never from a script in version control.
CREATE LOGIN ordering_app WITH PASSWORD = '<from-secret-store>';

USE ordering;
CREATE USER ordering_app FOR LOGIN ordering_app;

-- A role describing a job, so permissions are auditable in one place
CREATE ROLE ordering_app_role;

-- Read what it displays
GRANT SELECT ON dbo.customers    TO ordering_app_role;
GRANT SELECT ON dbo.products     TO ordering_app_role;
GRANT SELECT ON dbo.orders       TO ordering_app_role;
GRANT SELECT ON dbo.order_items  TO ordering_app_role;

-- Write only what it is responsible for
GRANT INSERT, UPDATE ON dbo.orders      TO ordering_app_role;
GRANT INSERT, UPDATE ON dbo.order_items TO ordering_app_role;

-- Run the procedures it is meant to use
GRANT EXECUTE ON dbo.usp_submit_order TO ordering_app_role;

ALTER ROLE ordering_app_role ADD MEMBER ordering_app;

-- Deliberately absent, and each absence is a decision:
--   no DELETE on any table        -- cancellation sets a status instead
--   no permission on employees    -- salaries are not this application's business
--   no ALTER or CREATE            -- schema changes run under a separate account
--   no membership of db_owner     -- see the warning below
  • Permissions are granted per table and per action, so SELECT on a table does not imply the ability to change it. That granularity is what makes least privilege achievable rather than aspirational.
  • No DELETE anywhere is the change with the largest effect for the least effort. Most business data should not be destroyed in the first place — a cancelled order becomes a cancelled order, not a missing one — and an account without DELETE cannot lose rows to a faulty WHERE clause.
  • Nothing is granted on employees. The ordering application never displays a salary, so a defect or a compromise in it cannot read one. Deciding what an account cannot reach is as much a part of the design as deciding what it can.
  • Migrations run under a different account with schema permissions, used by the deployment pipeline and not by the running application. That way an application with a SQL defect cannot drop a table, because it has no permission to.
  • The password placeholder is deliberate. Credentials belong in a secret store or a managed identity, and a connection string with a real password must not be in version control, in a container image, or in a script like this one.
  • Roles also make review possible. "What can the ordering application do?" is answered by reading one role's grants, rather than by auditing whatever accumulated on an account over four years.

Parameterised queries are the structural fix for injection. SQL injection happens when a value supplied by a user is combined into the text of a statement. Once a value is part of the statement text, the database has no way to know which characters were supposed to be data, so anything in that value that looks like SQL syntax is treated as part of the command.

The fix is structural rather than defensive. A parameterised query sends the statement and the values to the database separately. The statement's structure is fixed before any value is attached, so a value can never change what the statement does — it is data, and it stays data.

This is why input filtering is the weaker answer. Escaping quotes, stripping keywords or validating with a pattern all attempt to guess which values are dangerous. Parameters remove the category of danger, and they are less code than the filtering they replace.

The same query built two ways
C#
// Vulnerable: the value becomes part of the statement text.
// Whatever the user typed is parsed as SQL along with the rest.
var unsafeSql = $"SELECT customer_id, company_name FROM customers WHERE country = '{country}'";
using var bad = new SqlCommand(unsafeSql, connection);

// Safe: the statement text is fixed, and the value travels separately.
const string sql = @"
    SELECT customer_id, company_name
    FROM   customers
    WHERE  country = @country
      AND  credit_limit >= @minimumCredit";

using var command = new SqlCommand(sql, connection);
command.Parameters.Add("@country", SqlDbType.NVarChar, 80).Value = country;
command.Parameters.Add("@minimumCredit", SqlDbType.Decimal).Value = minimumCredit;

using SqlDataReader reader = await command.ExecuteReaderAsync();
  • In the first version the statement text is assembled from a value, so its structure depends on what the value contains. That dependency is the whole vulnerability — not the absence of validation, but the fact that data and command have been mixed.
  • In the second version the text containing @country is fixed at compile time and never changes. The value is sent alongside it as a parameter, and the database binds it after parsing, so it cannot affect the statement's meaning.
  • Declaring the type and length on the parameter is worth the extra characters. It documents what the column expects, avoids a silent conversion, and helps the database reuse the cached plan for the query.
  • The same rule applies through every layer. Stored procedures called with parameters are safe; a stored procedure that concatenates its arguments into dynamic SQL is not, and the risk is easier to miss because it looks like a procedure call from outside.
  • If you use an ORM such as Entity Framework Core, LINQ queries are parameterised for you. The exceptions to watch for are raw SQL methods and string interpolation into them — FromSqlRaw with a concatenated string reintroduces exactly this problem, while FromSqlInterpolated parameterises the interpolated values.
  • Note what parameters do not do: they do not authorise anything. A parameterised query running as an administrative account still has administrative reach. Least privilege and parameterisation address different problems and you need both.

Encryption protects data in two different situations — they are separate settings and one does not imply the other — and the cheapest protection of all is not holding the data:

In transit
Protects the data while it travels between your application and the database server. Achieved with TLS on the connection, which means Encrypt=True in a SQL Server connection string and a certificate the client genuinely validates. Without it, anything on the network path can read query text and results — including credentials.
At rest
Protects the data in the files on disk, including backups. Transparent Data Encryption in SQL Server and equivalents elsewhere encrypt the database files, so a stolen disk, a copied backup file or a decommissioned server does not hand over a readable database.
What at-rest encryption does not do
It is invisible to anyone connecting normally. An account with SELECT permission reads plain values, because the engine decrypts as it reads. It protects the files, not the queries, which is why permissions remain the primary control.
Column-level protection
For a small number of genuinely sensitive columns, encrypting the value itself keeps it unreadable even to someone who can query the table. It costs you the ability to search, sort or index that column usefully, so it is applied to specific columns after a decision, not across a schema.
Hashing, which is not encryption
Encryption is reversible by design; hashing is not. Passwords are hashed with a purpose-built, deliberately slow algorithm and a per-value salt, and are never encrypted or stored in any recoverable form. If a system can email a user their existing password, it is storing it wrongly.
Not storing it at all
Every value you keep is a value you have to defend, back up and account for. Do not collect fields nobody uses — a date of birth on a supplier contact, captured because the form had room, is a liability with no matching benefit. Where a provider offers a reference, store that instead: payment providers return a token for a stored card so the number never reaches your schema.
Keeping less of what you must keep
The last four digits of an account number support customer service; the whole number usually supports nothing your application does. Decide a retention period and write it down — retention that is nobody's job means everything is kept forever, including records from systems you no longer run.

Summary

  • Grant each account only the actions it needs, through a role, and treat every omission as a deliberate decision
  • The application account must not be an administrator: permissions decide how large any defect or mistake becomes
  • Parameterised queries separate command from data, which removes injection structurally rather than filtering for it
  • TLS protects data in transit and at-rest encryption protects the files; neither limits what an authorised connection can read
  • Storing less is the cheapest protection — collect what you use, keep tokens instead of sensitive values, and keep production data out of test environments

Practice

Attempt each one before opening the solution. Getting it wrong first is how the idea sticks.

Try it yourself

Try it yourself

A reporting tool needs read-only access to order and product data for a dashboard. It must not see employee salaries, and it must not be able to change anything.

Write the statements that create a role for it, grant what it needs, and add an account to that role. Then say what you would check afterwards to confirm the account cannot do more than intended.

Show solution

Grant SELECT on the four tables the dashboard reads and nothing else. No INSERT, UPDATE or DELETE, and no permission on employees at all — not even SELECT, because a read-only account that can read salaries is still a data exposure.

If the dashboard needs employee names to show who took an order, the answer is not to grant SELECT on employees. It is a view exposing only employee_id, first_name and last_name, with SELECT granted on the view and not on the table underneath. A view is the standard way to give an account part of a table.

What to check afterwards, and it is worth doing rather than assuming: connect as that account and try the things it should not be able to do. Select from employees, update an order, delete a row. Each one should be refused. A permission model that has not been tested is a permission model you are hoping about.

Also check what the account can reach beyond this database. A login can have server-level permissions and membership of server roles that table grants say nothing about, and a reporting account with server-wide read access defeats the whole exercise.

SQL
USE ordering;

CREATE ROLE reporting_read_role;

GRANT SELECT ON dbo.orders      TO reporting_read_role;
GRANT SELECT ON dbo.order_items TO reporting_read_role;
GRANT SELECT ON dbo.products    TO reporting_read_role;
GRANT SELECT ON dbo.customers   TO reporting_read_role;

-- Only the employee columns the dashboard displays
CREATE VIEW dbo.vw_employee_names AS
SELECT employee_id, first_name, last_name
FROM   dbo.employees;

GRANT SELECT ON dbo.vw_employee_names TO reporting_read_role;
-- and deliberately nothing on dbo.employees

CREATE USER reporting_tool FOR LOGIN reporting_tool;
ALTER ROLE reporting_read_role ADD MEMBER reporting_tool;

-- Then verify by connecting as reporting_tool: each of these must be refused.
-- SELECT salary FROM dbo.employees;
-- UPDATE dbo.orders SET status = 'cancelled' WHERE order_id = 1043;

Think about it

Think about it

Your application connects as a member of db_owner. Every query in the codebase is parameterised, the connection uses TLS, and the database files are encrypted at rest.

A colleague concludes that the account's permissions no longer matter, since there is no injection risk and the data is encrypted. What would you say?

Show solution

The measures listed are all worth having, and each addresses a different problem from the one the account creates. TLS protects data on the network. At-rest encryption protects the files. Parameterisation stops values being parsed as SQL. None of them limits what a query is permitted to do once it runs.

The permissions decide the size of any mistake. An UPDATE that ships without its WHERE clause rewrites every row in the table — no injection, no attacker, just a defect. A migration pointed at the wrong environment drops production tables. An application bug that builds one dynamic statement, months after the code review that checked the others, has administrative reach.

"Every query is parameterised" is also a statement about today. It holds until somebody adds a raw SQL call under time pressure, and the account's permissions are what decides whether that mistake is a bug or an incident.

At-rest encryption is the weakest part of the argument. It is transparent to any authorised connection, so an administrative account reads every value in plain text. It defends against a stolen disk or a leaked backup file, which is a real and different threat.

What to propose: enumerate the tables and actions the application genuinely uses, create a role with exactly those, and run the test suite against it. Expect a few failures, and expect them to be informative — each one is something the application was doing that nobody had accounted for. Then keep the administrative account for migrations, used by the deployment pipeline alone.

Knowledge check

Nothing is recorded and there is no score. The explanation appears either way.

Why is a parameterised query a structural fix for injection rather than a mitigation?
What is the main risk of an application connecting as a member of db_owner?
Encryption at rest is enabled on your database. What does it protect against?

Saved in this browser only.

End of the published lessons

That is everything written so far in SQL & Databases

More lessons in this course are on the way. In the meantime, the course page shows the full roadmap, and the projects are the best way to consolidate what you have covered.