SQL Server 2025 Security Hardening on a Windows Server VPS (2026 MSP Guide)
Step-by-step SQL Server 2025 hardening on a Windows Server VPS: disable sa, least-privilege app logins, SQL Server Audit, TCP/IP enable, firewall scoping, and Let's Encrypt cert replacement. End-to-end MSP guide tested on Raff.
On this page
Don't have a Windows Server yet?
Deploy Windows Server 2019/2022/2025 in ~2 minutes. 6-month evaluation licence included.
In short
SQL Server 2025 ships with much stronger default security than 2022 — mandatory TLS 1.3 encryption, PBKDF2 password hashing, RC4 disabled, TLS 1.0/1.1 deprecated. But three things still need manual hardening on every Raff Server install: replace the self-signed certificate with a real one (Let's Encrypt works), disable the sa account and use least-privilege application logins, and enable SQL Server Audit so you actually know who logged in and what they did. This guide walks through each step with T-SQL examples, verified on a live SQL Server 2025 Standard Developer install on Windows Server 2025.
Who this guide is for
You've installed SQL Server 2025 on a Raff Server (see our SQL Server 2025 install guide) and you want to take it to a production-ready security posture before exposing the instance to applications, internal users, or the internet. This guide is written for MSPs and DBAs who deploy SQL Server for clients and need a defensible, documented hardening checklist they can re-use.
If you're a solo developer doing local dev work, you can skim Part 2 ("What you still need to do") and skip the Audit and certificate sections — they're for production deployments.
What you'll need
- A working SQL Server 2025 install on a Raff Windows Server, with the Database Engine running and reachable from your admin workstation
- Sysadmin access to the SQL instance — either via
sa(which we'll disable as part of this guide) or a Windows-authenticated admin (preferred, what we'll use throughout) - PowerShell admin access to the Raff Server — we use
Invoke-Sqlcmdfor most steps - A domain you control if you plan to follow Part 2's certificate replacement section — Let's Encrypt cannot issue certs for IP addresses or for Raff's rDNS hostnames, you need your own DNS-resolvable name
- Estimated time: 60-90 minutes for the full hardening pass
About the examples in this guide
Several commands in this guide contain placeholder values you must substitute before running. They are intentionally documentation-friendly (RFC 5737 IPs, example domains, obvious password patterns) so they will not work in production without replacement:
| Placeholder | Replace with |
|---|---|
customeracme.com / sql.customeracme.com | A domain you actually own and control |
203.0.113.10/32, 198.51.100.42/32 | Your admin/office public IP (find via ifconfig.me or ipinfo.io) |
TempStrongPassword2026!ReplaceMe | A 16+ character password generated by your password manager |
StrongMasterKeyPassword2026!, StrongCertPrivateKeyPassword2026! | Distinct strong passwords stored in your password manager and a backup off-server |
PASTE_THUMBPRINT_HERE_LOWERCASE | The actual SHA-1 thumbprint of your certificate (from Get-ChildItem Cert:\LocalMachine\My) |
<Raff Server public IP> | Your Raff Server's actual public IPv4 address |
If a command produces a "login failed" or "address invalid" error after you run it, double-check that you substituted every placeholder. Copy-pasting verbatim will not work.
Part 1 — What's already secure-by-default in SQL Server 2025
Microsoft made significant security changes in SQL Server 2025 that prior hardening guides assumed you had to enable manually. You don't. Skip these, but verify they're actually on.
TLS 1.3 + TDS 8.0 enforced encryption
Per Microsoft's "Secure by default" announcement, SQL Server 2025 uses TDS 8.0 + TLS 1.3 for several internal communication paths by default — SQL Server Agent, replication, linked servers, log shipping, Always On AGs. Encryption is enforced from the very first packet, no negotiation phase. Client connections from modern drivers (ODBC 18, OLE DB 19, .NET SqlClient) default to Encrypt=True.
This is a fundamental change. Old SQL hardening guides told you to "enable Force Encryption" — in 2025 it's effectively the default for first-party tools.
What you should still verify (rather than configure):
powershell# Check what TLS protocols Windows Schannel allows
$base = 'HKLM:\SYSTEM\CurrentControlSet\Control\SecurityProviders\SCHANNEL\Protocols'
foreach ($p in 'TLS 1.0','TLS 1.1','TLS 1.2','TLS 1.3') {
$path = "$base\$p\Server"
$val = (Get-ItemProperty -Path $path -Name Enabled -ErrorAction SilentlyContinue).Enabled
Write-Host "$p Server Enabled: $val"
}
On a fresh Windows Server 2025, this returns blank/null for all four. Don't panic — that's normal. When SCHANNEL registry keys don't exist, Windows uses its built-in defaults. On Server 2025 those defaults are: TLS 1.0 and 1.1 disabled, TLS 1.2 and 1.3 enabled. The absence of registry entries means "use defaults", not "everything is off."
You only need to set these registry keys if you want to explicitly disable TLS 1.2 in the future (TLS 1.3 only) or if you're chasing a compliance audit that requires explicit configuration.
PBKDF2 password hashing
Pre-2025, SQL Server hashed SQL login passwords with a single SHA-512 pass and a 32-bit salt. SQL 2025 replaces this with PBKDF2/RFC2898 using SHA-512 and 100,000 iterations. Brute-force attacks against stolen hash dumps are now orders of magnitude slower.
This is automatic — if you create a new SQL login on SQL 2025, it gets the new algorithm. There's nothing to configure. If you migrated logins from a 2022 instance, those keep their old hashes until the password is changed.
Practical implication: force a password rotation on every SQL login during your 2022 → 2025 migration so they pick up the stronger hash.
Other secure-by-default items
- RC4 Kerberos disabled by Windows Server 2025's security baseline
- TLS 1.0 / 1.1 disabled at the OS level
- NTLMv1 removed in Windows 11 24H2 / Server 2025
- xp_cmdshell defaults to disabled (verify, but don't expect to find it on)
You can verify xp_cmdshell quickly:
powershellInvoke-Sqlcmd -ServerInstance "localhost" -Query "SELECT name, value, value_in_use FROM sys.configurations WHERE name = 'xp_cmdshell'"
Expected: both value and value_in_use are 0. If anything is 1, see Part 2's xp_cmdshell section.
Part 2 — What you still need to harden manually
The default install gets you most of the way, but five things remain on every Raff Server SQL 2025 deployment.
1. Disable the sa account
The sa (system administrator) login is the SQL Server equivalent of root — it has full server-level control, can't be removed, and is the #1 brute-force target on internet-exposed SQL Servers. Even with strong passwords, the existence of an enabled sa account is a liability.
Before you disable, make sure you have an alternative sysadmin — your Windows Authenticated Administrator added during install is the standard fallback:
powershellInvoke-Sqlcmd -ServerInstance "localhost" -Query @"
SELECT
sl.name AS LoginName,
sl.is_disabled,
CASE WHEN srm.role_principal_id IS NOT NULL THEN 'YES' ELSE 'no' END AS IsSysadmin
FROM sys.sql_logins sl
LEFT JOIN sys.server_role_members srm
ON sl.principal_id = srm.member_principal_id
AND srm.role_principal_id = (SELECT principal_id FROM sys.server_principals WHERE name = 'sysadmin')
WHERE sl.name = 'sa'
UNION ALL
SELECT
sp.name,
sp.is_disabled,
CASE WHEN srm.role_principal_id IS NOT NULL THEN 'YES' ELSE 'no' END
FROM sys.server_principals sp
LEFT JOIN sys.server_role_members srm
ON sp.principal_id = srm.member_principal_id
AND srm.role_principal_id = (SELECT principal_id FROM sys.server_principals WHERE name = 'sysadmin')
WHERE sp.type IN ('U','G') AND sp.is_disabled = 0
"@
You should see at least one Windows login with IsSysadmin: YES (typically HOSTNAME\Administrator). Confirm you can log in using that account through SSMS or sqlcmd -E (Trusted Connection) before disabling sa.
Disable sa:
powershellInvoke-Sqlcmd -ServerInstance "localhost" -Query "ALTER LOGIN sa DISABLE;"
# Verify
Invoke-Sqlcmd -ServerInstance "localhost" -Query @"
SELECT name, type_desc, is_disabled, modify_date
FROM sys.server_principals
WHERE name = 'sa'
"@
Expected: is_disabled: True, modify_date stamped to right now.
Optional: also rename the sa account
Some MSPs additionally rename sa to make brute-force scripts (which usually try the literal name "sa") miss the target entirely. Both rename and disable is the most defensible posture:
sql-- Rename first, then disable the renamed account
ALTER LOGIN sa WITH NAME = sa_disabled_20260430;
ALTER LOGIN sa_disabled_20260430 DISABLE;
This breaks any application or script that hardcoded sa as a connection username — which is exactly the point. Document the renamed login somewhere visible to anyone who'll inherit the server (runbook, secrets manager note, etc.) so emergency recovery doesn't get blocked by name confusion.
What if you accidentally lock yourself out?
If you disable sa AND lose access to your Windows admin login, recovery requires single-user-mode tricks — boot SQL with -m flag, log in as a Windows admin, re-enable sa or grant another login sysadmin. Microsoft documents the procedure at Connect to SQL Server when system administrators are locked out.
2. Create least-privilege application logins
Every application that talks to SQL Server should have its own login, scoped to the specific database it needs, with the minimum permissions to do its work. Not sysadmin. Not db_owner. Almost never has cross-database permissions.
Here's a complete example creating a login for a hypothetical app called RaffApp01:
sql-- Create the application database (skip if it already exists)
CREATE DATABASE RaffApp01;
-- Create the server-level login with strong policy enforcement
CREATE LOGIN app_raffapp01
WITH PASSWORD = 'TempStrongPassword2026!ReplaceMe',
DEFAULT_DATABASE = RaffApp01,
CHECK_EXPIRATION = ON,
CHECK_POLICY = ON;
-- Map login to a database user inside RaffApp01
USE RaffApp01;
CREATE USER app_raffapp01 FOR LOGIN app_raffapp01;
-- Grant only what an app needs: read + write within this DB
ALTER ROLE db_datareader ADD MEMBER app_raffapp01;
ALTER ROLE db_datawriter ADD MEMBER app_raffapp01;
-- Optional: allow EXECUTE on stored procedures (if your app uses procs)
-- GRANT EXECUTE TO app_raffapp01;
Key points in this script:
CHECK_EXPIRATION = ON— password is subject to Windows password policy expiration. The app's password rotation becomes a forced operational discipline rather than a forgotten config item.CHECK_POLICY = ON— Windows complexity rules apply. SQL refuses passwords below the local security policy minimum (length, character classes).DEFAULT_DATABASE— login lands in its app database by default, notmaster. Limits the blast radius of a misconfigured connection string.db_datareaderanddb_datawriteronly — login can read and write data in this DB but cannot create/drop tables, modify schema, grant permissions to others, or touch any other database.
Verify it worked:
powershellInvoke-Sqlcmd -ServerInstance "localhost" -Query @"
SELECT
sl.name AS Login,
sl.is_disabled,
sl.default_database_name,
sl.is_policy_checked,
sl.is_expiration_checked,
CASE WHEN srm.role_principal_id IS NOT NULL THEN 'YES' ELSE 'no' END AS IsSysadmin
FROM sys.sql_logins sl
LEFT JOIN sys.server_role_members srm
ON sl.principal_id = srm.member_principal_id
AND srm.role_principal_id = (SELECT principal_id FROM sys.server_principals WHERE name = 'sysadmin')
WHERE sl.name = 'app_raffapp01'
"@
Expected output:
is_disabled: Falsedefault_database_name: RaffApp01is_policy_checked: Trueis_expiration_checked: TrueIsSysadmin: no← the most important line
And inside the database:
powershellInvoke-Sqlcmd -ServerInstance "localhost" -Database "RaffApp01" -Query @"
SELECT dp.name AS DBUser, USER_NAME(rm.role_principal_id) AS RoleMembership
FROM sys.database_principals dp
LEFT JOIN sys.database_role_members rm ON dp.principal_id = rm.member_principal_id
WHERE dp.name = 'app_raffapp01'
"@
Expected: two rows — one for db_datareader, one for db_datawriter. No db_owner.
Why this matters: the db_owner and sysadmin trap
A startlingly large number of production SQL deployments give every app login db_owner (or worse, sysadmin). Two reasons:
- It's easier. Devs hit a permission error during local testing, the DBA grants db_owner to make the error go away, and that "temporary" grant ships to production.
- Frameworks/ORMs sometimes need it. Some EF migrations, Hibernate schema updates, etc. want DDL rights to auto-create tables.
For Reason 1: don't. Diagnose the actual permission needed and grant only that.
For Reason 2: have two logins per app — a "schema owner" login used only during deployments (db_owner permissions, used by your migration tool), and a "runtime" login used by the live app (db_datareader + db_datawriter). The runtime login can never accidentally drop a table.
3. Enable SQL Server Audit
SQL Server Audit is the canonical answer to "who logged in and what did they do" in any production SQL deployment. It's a free feature of all editions including Standard Developer and Express. There is no excuse not to use it.
We'll create:
- A Server Audit (the WHERE — file path on disk for log storage)
- A Server Audit Specification (the WHAT — which events to log)
- Then enable both
powershell# Create the audit folder first
New-Item -ItemType Directory -Path "C:\SQL\Audit" -Force
sql-- 1. Create the audit target (where logs go)
CREATE SERVER AUDIT Raff_SecurityAudit
TO FILE (
FILEPATH = 'C:\SQL\Audit\',
MAXSIZE = 100 MB,
MAX_ROLLOVER_FILES = 10,
RESERVE_DISK_SPACE = OFF
)
WITH (
QUEUE_DELAY = 1000,
ON_FAILURE = CONTINUE
);
-- 2. Create the audit specification (what events to log)
CREATE SERVER AUDIT SPECIFICATION Raff_SecurityAudit_Spec
FOR SERVER AUDIT Raff_SecurityAudit
ADD (FAILED_LOGIN_GROUP),
ADD (SUCCESSFUL_LOGIN_GROUP),
ADD (LOGOUT_GROUP),
ADD (SERVER_ROLE_MEMBER_CHANGE_GROUP),
ADD (DATABASE_ROLE_MEMBER_CHANGE_GROUP),
ADD (SERVER_PERMISSION_CHANGE_GROUP),
ADD (DATABASE_PERMISSION_CHANGE_GROUP),
ADD (SERVER_OBJECT_PERMISSION_CHANGE_GROUP),
ADD (DATABASE_OBJECT_PERMISSION_CHANGE_GROUP),
ADD (SCHEMA_OBJECT_CHANGE_GROUP),
ADD (DATABASE_PRINCIPAL_CHANGE_GROUP),
ADD (SERVER_PRINCIPAL_CHANGE_GROUP),
ADD (DATABASE_CHANGE_GROUP)
WITH (STATE = ON);
-- 3. Enable the audit target
ALTER SERVER AUDIT Raff_SecurityAudit
WITH (STATE = ON);
What each action group captures
| Group | Captures |
|---|---|
| FAILED_LOGIN_GROUP | Every failed login attempt (your brute-force detector) |
| SUCCESSFUL_LOGIN_GROUP | Every successful login (your forensic timeline) |
| LOGOUT_GROUP | Session terminations |
| SERVER_ROLE_MEMBER_CHANGE_GROUP | "X was added to sysadmin" |
| DATABASE_ROLE_MEMBER_CHANGE_GROUP | "X was added to db_owner in DB Y" |
| SERVER_PERMISSION_CHANGE_GROUP | GRANT / REVOKE / DENY at server level |
| DATABASE_PERMISSION_CHANGE_GROUP | GRANT / REVOKE / DENY at database level |
| SERVER_OBJECT_PERMISSION_CHANGE_GROUP | Permission changes on server-level objects |
| DATABASE_OBJECT_PERMISSION_CHANGE_GROUP | Permission changes on tables, views, procs |
| SCHEMA_OBJECT_CHANGE_GROUP | CREATE / ALTER / DROP table, view, proc |
| DATABASE_PRINCIPAL_CHANGE_GROUP | CREATE / ALTER / DROP USER |
| SERVER_PRINCIPAL_CHANGE_GROUP | CREATE / ALTER / DROP LOGIN |
| DATABASE_CHANGE_GROUP | CREATE / ALTER / DROP DATABASE |
Together these capture: every login attempt, every change to who has access to what, every schema modification. That's the foundation for both compliance reporting and incident response.
Verify the audit is running
powershellInvoke-Sqlcmd -ServerInstance "localhost" -Query "SELECT name, type_desc, is_state_enabled, create_date FROM sys.server_audits"
Invoke-Sqlcmd -ServerInstance "localhost" -Query @"
SELECT sas.name, sas.is_state_enabled, sasd.audit_action_name
FROM sys.server_audit_specifications sas
JOIN sys.server_audit_specification_details sasd
ON sas.server_specification_id = sasd.server_specification_id
ORDER BY sas.name, sasd.audit_action_name
"@
Both queries should return rows showing is_state_enabled: True.
Read the audit log
To prove it works, trigger a failed login and read it back:
powershell# Trigger a deliberate failed login
try {
Invoke-Sqlcmd -ServerInstance "localhost" -Username "sa" -Password "WrongPassword123!" -Query "SELECT 1" -ErrorAction Stop
} catch {
Write-Host "Failed login triggered (expected)" -ForegroundColor Yellow
}
# Wait for the audit queue to flush (we set QUEUE_DELAY = 1000ms)
Start-Sleep -Seconds 3
# Read the audit log
Invoke-Sqlcmd -ServerInstance "localhost" -Query @"
SELECT TOP 20
event_time,
action_id,
succeeded,
server_principal_name,
client_ip,
application_name,
statement
FROM sys.fn_get_audit_file('C:\SQL\Audit\*.sqlaudit', DEFAULT, DEFAULT)
WHERE action_id IN ('LGIF', 'LGIS')
ORDER BY event_time DESC
"@
You should see the failed login captured with:
action_id: LGIF(LGIF = Login Failed; LGIS = Login Successful)succeeded: Falseserver_principal_name: sastatement: Login failed for user 'sa'. Reason: An error occurred while evaluating the password.
The "evaluating the password" reason text is specific to disabled accounts — different from "wrong password" for an enabled account. SQL gives you actionable forensic detail in the audit log itself.
A note on client_ip
In our test, client_ip showed as local machine rather than 127.0.0.1. This happens because connections to localhost from a SqlClient driver on the same box use LPC (Local Procedure Call / Shared Memory) protocol, not TCP/IP. The audit log records the protocol type rather than spoofing a TCP source IP. Remote TCP connections will show actual IP addresses.
Querying the audit log for incident response
This is the kind of query you'd run after suspicious activity:
sql-- All failed logins in the last 24 hours, grouped by source IP
SELECT
client_ip,
server_principal_name,
COUNT(*) AS FailedAttempts,
MIN(event_time) AS FirstAttempt,
MAX(event_time) AS LastAttempt
FROM sys.fn_get_audit_file('C:\SQL\Audit\*.sqlaudit', DEFAULT, DEFAULT)
WHERE action_id = 'LGIF'
AND event_time > DATEADD(HOUR, -24, GETUTCDATE())
GROUP BY client_ip, server_principal_name
ORDER BY FailedAttempts DESC;
-- All schema changes in the last 7 days
SELECT
event_time,
server_principal_name,
database_name,
schema_name,
object_name,
statement
FROM sys.fn_get_audit_file('C:\SQL\Audit\*.sqlaudit', DEFAULT, DEFAULT)
WHERE action_id IN ('CR', 'AL', 'DR')
AND event_time > DATEADD(DAY, -7, GETUTCDATE())
ORDER BY event_time DESC;
Audit log retention
Our config (MAXSIZE = 100 MB, MAX_ROLLOVER_FILES = 10) gives ~1 GB of rolling logs. For compliance scenarios that require longer retention:
- Pull logs to centralized storage — robocopy
C:\SQL\Audit\*.sqlauditto a network share or S3 bucket on a daily schedule - Forward to SIEM — many SIEM tools (Splunk, Sentinel, Datadog) have SQL Server Audit collectors
- Increase MAXSIZE and MAX_ROLLOVER_FILES if disk space allows
4. Enable TCP/IP and tighten the firewall
This is the most surprising finding from our live testing: SQL Server 2025 with a default-instance Custom install ships with TCP/IP DISABLED at the protocol level. All your local Invoke-Sqlcmd calls work because they use Shared Memory. But no remote client can connect to your instance until you explicitly enable TCP/IP.
Old install guides (and even many SQL 2022 walkthroughs) assume TCP is on. Verify yours:
powershell$tcpKey = 'HKLM:\SOFTWARE\Microsoft\Microsoft SQL Server\MSSQL17.MSSQLSERVER\MSSQLServer\SuperSocketNetLib\Tcp'
Get-ItemProperty -Path $tcpKey | Select-Object Enabled, ListenOnAllIPs, DisplayName
If Enabled: 0, TCP is off and remote clients cannot reach your instance. Enable it:
powershell# Enable TCP/IP
Set-ItemProperty -Path $tcpKey -Name Enabled -Value 1
# Restart SQL Server service for the change to take effect
Restart-Service -Name MSSQLSERVER -Force
# Verify
Get-ItemProperty -Path $tcpKey | Select-Object Enabled, ListenOnAllIPs, DisplayName
# Confirm SQL is now listening
Test-NetConnection -ComputerName localhost -Port 1433 -InformationLevel Quiet
Test-NetConnection should now return True.
Add the firewall rule with tight scope
Now that TCP is reachable on the server side, restrict who can reach it. Never create a TCP 1433 rule with RemoteAddress = Any and leave it that way — automated scanners will find it within minutes and start brute-forcing.
powershell# Create the rule with a placeholder Any scope first
New-NetFirewallRule `
-DisplayName "SQL Server (TCP 1433)" `
-Direction Inbound `
-Protocol TCP `
-LocalPort 1433 `
-RemoteAddress Any `
-Action Allow `
-Profile Any
# Immediately tighten — replace 203.0.113.10 with YOUR admin/office public IP
Set-NetFirewallRule `
-DisplayName "SQL Server (TCP 1433)" `
-RemoteAddress "203.0.113.10/32"
# Verify
Get-NetFirewallRule -DisplayName "SQL Server (TCP 1433)" |
Get-NetFirewallAddressFilter |
Select-Object @{n='Rule';e={'SQL Server (TCP 1433)'}}, RemoteAddress, LocalAddress
Find your admin public IP by visiting ifconfig.me or ipinfo.io from your admin workstation. Substitute it for 203.0.113.10/32 (which is an RFC 5737 documentation-only IP that doesn't route anywhere — safe to use as a placeholder, useless for real access control).
For multiple admin IPs, use a comma-separated list: -RemoteAddress "203.0.113.10/32, 198.51.100.42/32".
For a small office with a dynamic IP block, use the CIDR: -RemoteAddress "203.0.113.0/24".
Better than IP allowlisting: tunneled access
For production deployments where your admin IP changes (working from home, traveling, hot-desking), IP allowlisting becomes operational pain. Three better patterns:
- VPN to your Raff Server's network, then 1433 only allows
LocalSubnet - SSH tunnel through a separate Raff Server acting as a bastion, then 1433 listens on
127.0.0.1only and is reached via local-port-forward - Cloudflare Tunnel or similar zero-trust proxy with per-user identity-based access
For most MSP deployments, IP allowlisting is fine and the simplest answer. Use the better patterns when client requirements push you there.
5. Replace SQL Server's self-signed certificate
Even though encryption is on by default in 2025, the certificate SQL Server ships with is auto-generated and self-signed. This is the weakest link in the secure-by-default chain because:
- ODBC Driver 18 / OLE DB Driver 19 reject self-signed certs by default (
TrustServerCertificate=Falseis the new default) - Linked servers between SQL 2025 instances break entirely if one side uses a self-signed cert
- Apps connecting with modern .NET drivers see "certificate chain not trusted" errors
- Replication, log shipping, Always On all fail
You have three workable choices:
- Let's Encrypt (recommended for most Raff deployments) — free, public CA, trusted by every modern OS automatically. Requires a domain name you control.
- Internal Certificate Authority — for AD-joined environments with existing PKI infrastructure. More setup, more enterprise.
- Properly-formed self-signed cert with SAN entries — for fully air-gapped deployments without DNS or internal CA.
This guide covers the Let's Encrypt path because it's the most common Raff Server scenario and the simplest to operate.
Prerequisite: a domain name and DNS record
You need a domain you own (e.g. customeracme.com). Raff's reverse-DNS hostnames (xxx-xxx-xxx-xxx.raffusercloud.com) cannot be used for Let's Encrypt — the customer needs to bring their own domain.
Create a DNS A record pointing your chosen subdomain at the Raff Server's public IP:
sql.customeracme.com. A <Raff Server public IP>
Wait for DNS propagation (5-15 minutes typically; you can check with nslookup sql.customeracme.com 8.8.8.8).
Install win-acme
win-acme is the most reliable Windows ACME client for Let's Encrypt. Download the latest release ZIP, extract to C:\Program Files\win-acme\, run as admin.
Issue the certificate
For SQL Server, we want a certificate stored in the Local Machine\Personal certificate store, with the private key marked exportable so SQL can read it. win-acme handles this:
powershell# From an admin PowerShell session
cd "C:\Program Files\win-acme"
.\wacs.exe `
--target manual `
--host sql.customeracme.com `
--validation http-01 `
--validationport 80 `
--store certificatestore `
--certificatestore My `
--installation script `
--script "scripts\ImportSqlServer.ps1"
The HTTP-01 validation requires port 80 to be temporarily reachable from the internet — Let's Encrypt's servers will hit http://sql.customeracme.com/.well-known/acme-challenge/... to verify domain control. If port 80 is blocked, use DNS-01 validation instead (requires DNS API integration).
Bind the certificate to SQL Server
After win-acme imports the certificate, find its thumbprint:
powershellGet-ChildItem -Path Cert:\LocalMachine\My |
Where-Object Subject -like "*sql.customeracme.com*" |
Select-Object Subject, Thumbprint, NotAfter
Copy the thumbprint, then write it into SQL Server's registry:
powershell$thumbprint = "PASTE_THUMBPRINT_HERE_LOWERCASE"
$regPath = 'HKLM:\SOFTWARE\Microsoft\Microsoft SQL Server\MSSQL17.MSSQLSERVER\MSSQLServer\SuperSocketNetLib'
Set-ItemProperty -Path $regPath -Name Certificate -Value $thumbprint
# Grant the SQL service account read access to the private key
$cert = Get-ChildItem -Path Cert:\LocalMachine\My\$thumbprint
$keyPath = "$env:ProgramData\Microsoft\Crypto\RSA\MachineKeys\$($cert.PrivateKey.CspKeyContainerInfo.UniqueKeyContainerName)"
$acl = Get-Acl $keyPath
$rule = New-Object System.Security.AccessControl.FileSystemAccessRule("NT Service\MSSQLSERVER", "Read", "Allow")
$acl.AddAccessRule($rule)
Set-Acl $keyPath $acl
# Restart SQL Server to pick up the new cert
Restart-Service -Name MSSQLSERVER -Force
Verify the new certificate is in use:
powershell# Check the SQL error log — it'll log which certificate it loaded
Get-Content "C:\Program Files\Microsoft SQL Server\MSSQL17.MSSQLSERVER\MSSQL\Log\ERRORLOG" |
Select-String -Pattern "certificate"
You should see a line like The certificate [Cert Hash(sha1) "..."] was successfully loaded for encryption. matching your new thumbprint.
Auto-renewal
Let's Encrypt certs expire every 90 days. win-acme installs a Windows scheduled task at install that runs daily and renews any certs ≤ 30 days from expiry. It also re-runs the ImportSqlServer.ps1 post-install script automatically — but the SQL Service won't reload the new cert until restart. Add a service restart to your renewal script, or accept that you'll restart SQL once every 90 days at renewal time (most production SQL deployments restart for cumulative updates more often than that anyway).
6. TDE — Transparent Data Encryption (briefly)
TDE encrypts your database files at rest (.mdf, .ndf, .ldf) and backup files. If someone steals your disk image or backup file, they can't restore it without your encryption key.
TDE is available on Standard, Enterprise, and Developer editions. Express does not support TDE — if you're on Express, your data files are unencrypted at rest. If that's unacceptable, BitLocker on the underlying volume is a partial substitute.
We're not going deep on TDE in this article because:
- It involves a Database Master Key, a Server Certificate, and a Database Encryption Key — all of which need backup/recovery procedures or you can lose your data permanently
- Backup encryption (covered next) is often the bigger compliance win for less complexity
- Full TDE setup with proper key management deserves its own article
The minimum viable TDE setup:
sql-- 1. Master key in master DB
USE master;
CREATE MASTER KEY ENCRYPTION BY PASSWORD = 'StrongMasterKeyPassword2026!';
-- 2. Certificate in master DB
CREATE CERTIFICATE Raff_TDE_Cert WITH SUBJECT = 'Raff TDE Certificate';
-- 3. BACKUP THE CERTIFICATE IMMEDIATELY — losing it means losing your data
BACKUP CERTIFICATE Raff_TDE_Cert
TO FILE = 'C:\SQL\Backups\Raff_TDE_Cert.cer'
WITH PRIVATE KEY (
FILE = 'C:\SQL\Backups\Raff_TDE_Cert.pvk',
ENCRYPTION BY PASSWORD = 'StrongCertPrivateKeyPassword2026!'
);
-- 4. Enable TDE on a specific database
USE RaffApp01;
CREATE DATABASE ENCRYPTION KEY
WITH ALGORITHM = AES_256
ENCRYPTION BY SERVER CERTIFICATE Raff_TDE_Cert;
ALTER DATABASE RaffApp01 SET ENCRYPTION ON;
Critically: copy Raff_TDE_Cert.cer and Raff_TDE_Cert.pvk (and the password) to off-server secure storage. If the Raff Server fails and you have only the encrypted database files, you cannot restore them without these certificate files. This is the #1 way TDE causes data loss in practice.
7. Backup encryption
A simpler win than full TDE: encrypt just the backup files. This protects against backup theft / leakage without the operational complexity of TDE on the live data files.
Available on all editions of SQL Server 2025 — Express included.
sql-- Use the same Raff_TDE_Cert from the TDE section, OR create a backup-specific one
USE master;
CREATE MASTER KEY ENCRYPTION BY PASSWORD = 'StrongMasterKeyPassword2026!';
CREATE CERTIFICATE Raff_Backup_Cert WITH SUBJECT = 'Raff Backup Encryption';
-- Backup the certificate to off-server storage immediately
BACKUP CERTIFICATE Raff_Backup_Cert
TO FILE = 'C:\SQL\Backups\Raff_Backup_Cert.cer'
WITH PRIVATE KEY (
FILE = 'C:\SQL\Backups\Raff_Backup_Cert.pvk',
ENCRYPTION BY PASSWORD = 'StrongCertPrivateKeyPassword2026!'
);
-- Encrypted backup
BACKUP DATABASE RaffApp01
TO DISK = 'C:\SQL\Backups\RaffApp01_Encrypted.bak'
WITH COMPRESSION,
ENCRYPTION (
ALGORITHM = AES_256,
SERVER CERTIFICATE = Raff_Backup_Cert
);
Restoring an encrypted backup requires the certificate to exist in the destination instance. If you're restoring on a different server, restore the certificate first with CREATE CERTIFICATE FROM FILE.
Same warning as TDE: keep the cert + private key + password in off-server storage. Lose them and you've encrypted your own backups out of reach.
8. Patching cadence
Microsoft ships SQL Server 2025 cumulative updates roughly every 60-90 days. Each CU contains security fixes, bug fixes, and minor performance improvements. CU1 alone fixed CVE-2026-32176, an elevation-of-privilege vulnerability in linked servers.
Production patching standard:
- CU within 30 days of release for security-relevant CUs
- CU within 60 days for general CUs
- Subscribe to the SQL Server release notes RSS feed — Microsoft posts every CU here
- Apply CUs to a non-production instance first, run your test suite, then promote
- Take a database backup before applying any CU — CUs apply Windows-side and aren't trivially reversible
To check your current build vs available CUs:
powershellInvoke-Sqlcmd -ServerInstance "localhost" -Query "SELECT @@VERSION, SERVERPROPERTY('ProductLevel'), SERVERPROPERTY('ProductUpdateLevel')"
Compare the result to Microsoft's SQL Server 2025 build list.
Part 3 — MSP handoff checklist
For MSPs deploying SQL Server 2025 for clients, this is the scannable checklist to copy into your client runbook. Every box should be ticked before handing off.
SQL SERVER 2025 HARDENING — RAFF SERVER DEPLOYMENT CHECKLIST
INSTANCE BASICS
[ ] SQL Server 2025 installed on Windows Server 2025
[ ] Windows pending-restart cleared before install
[ ] Latest Cumulative Update applied (target: CU1 or later)
[ ] Mixed Mode auth (only if app requires SQL logins; otherwise Windows-only)
[ ] Volume Maintenance Tasks privilege granted to SQL service
[ ] SQL Server Agent set to Automatic startup
ACCOUNT HARDENING
[ ] sa account disabled
[ ] sa account renamed (optional but recommended for internet-exposed instances)
[ ] Windows admin account verified working as backup sysadmin
[ ] Per-application logins created with CHECK_POLICY = ON, CHECK_EXPIRATION = ON
[ ] Each app login has only db_datareader + db_datawriter (not db_owner, not sysadmin)
[ ] No app login has cross-database permissions
NETWORK HARDENING
[ ] TCP/IP enabled at SuperSocketNetLib registry level
[ ] SQL service restarted after enabling TCP/IP
[ ] Windows Firewall rule for TCP 1433 created
[ ] Firewall rule scoped to specific admin IPs (not Any)
[ ] No public exposure of TCP 1433 to 0.0.0.0/0
ENCRYPTION
[ ] Self-signed certificate replaced with trusted CA cert (Let's Encrypt or internal CA)
[ ] Certificate has FQDN + NetBIOS name in SAN
[ ] SQL service granted read access to certificate private key
[ ] win-acme renewal task scheduled (if using Let's Encrypt)
[ ] TDE configured if compliance requires (with certificate backup off-server)
[ ] Backup encryption configured (with certificate backup off-server)
AUDITING
[ ] Server Audit created with file target on dedicated path (C:\SQL\Audit\)
[ ] Server Audit Specification created with login, role, permission, schema, principal action groups
[ ] Both audit objects enabled (STATE = ON)
[ ] Failed login test event captured in audit log (verified via sys.fn_get_audit_file)
[ ] Audit log retention strategy defined (centralized storage / SIEM forwarding)
OPERATIONAL
[ ] Database backups configured with encryption + off-server storage
[ ] Backup encryption certificate backed up to off-server secure storage
[ ] Database Master Key backed up off-server
[ ] CU patching cadence agreed with client (30/60-day SLA)
[ ] SQL Server build documented in client runbook
[ ] Emergency contacts and recovery procedures documented
[ ] Audit log query templates saved for incident response
CLIENT HANDOFF
[ ] sa password and admin credentials in client password manager (not yours)
[ ] Database master key password in client password manager
[ ] Certificate private key password in client password manager
[ ] Documentation of all customizations vs Microsoft defaults
[ ] First-90-day support window defined
Part 4 — Verify the hardening worked
After completing all sections of Part 2, run this consolidated PowerShell script as an admin on the Raff Server. It checks every hardening item we configured and prints a green/red status. Use it as a smoke test after initial hardening, and re-run it on a schedule (weekly or monthly) to detect drift — someone re-enabling sa, an audit getting accidentally turned off, the firewall rule being widened, etc.
powershellWrite-Host "`n=== SQL Server 2025 Hardening Verification ===" -ForegroundColor Cyan
Write-Host "Run as Administrator on the Raff Server`n" -ForegroundColor DarkGray
# 1. sa account disabled
Write-Host "1. sa account state" -ForegroundColor Cyan
$saState = Invoke-Sqlcmd -ServerInstance "localhost" -Query "SELECT is_disabled FROM sys.server_principals WHERE name = 'sa'"
if ($saState.is_disabled) {
Write-Host " PASS: sa is disabled" -ForegroundColor Green
} else {
Write-Host " FAIL: sa is ENABLED — disable with ALTER LOGIN sa DISABLE;" -ForegroundColor Red
}
# 2. xp_cmdshell off
Write-Host "`n2. xp_cmdshell state" -ForegroundColor Cyan
$xp = Invoke-Sqlcmd -ServerInstance "localhost" -Query "SELECT value_in_use FROM sys.configurations WHERE name = 'xp_cmdshell'"
if ($xp.value_in_use -eq 0) {
Write-Host " PASS: xp_cmdshell is disabled" -ForegroundColor Green
} else {
Write-Host " FAIL: xp_cmdshell is ENABLED — disable via sp_configure 'xp_cmdshell', 0; RECONFIGURE;" -ForegroundColor Red
}
# 3. App logins are NOT sysadmin
Write-Host "`n3. SQL logins with sysadmin role (excluding sa)" -ForegroundColor Cyan
$sysadmins = Invoke-Sqlcmd -ServerInstance "localhost" -Query @"
SELECT sl.name
FROM sys.sql_logins sl
JOIN sys.server_role_members srm ON sl.principal_id = srm.member_principal_id
JOIN sys.server_principals sp ON srm.role_principal_id = sp.principal_id
WHERE sp.name = 'sysadmin' AND sl.name <> 'sa'
"@
if ($sysadmins) {
Write-Host " FAIL: SQL logins with sysadmin (review and remove unless intentional):" -ForegroundColor Red
$sysadmins | ForEach-Object { Write-Host " - $($_.name)" -ForegroundColor Red }
} else {
Write-Host " PASS: no non-sa SQL logins are sysadmin" -ForegroundColor Green
}
# 4. SQL logins missing password policy enforcement
Write-Host "`n4. SQL logins without CHECK_POLICY = ON" -ForegroundColor Cyan
$weakLogins = Invoke-Sqlcmd -ServerInstance "localhost" -Query @"
SELECT name FROM sys.sql_logins
WHERE is_policy_checked = 0
AND name NOT LIKE '##%'
AND name <> 'sa'
"@
if ($weakLogins) {
Write-Host " WARN: SQL logins without password policy:" -ForegroundColor Yellow
$weakLogins | ForEach-Object { Write-Host " - $($_.name)" -ForegroundColor Yellow }
} else {
Write-Host " PASS: all non-sa SQL logins enforce password policy" -ForegroundColor Green
}
# 5. SQL Server Audit enabled
Write-Host "`n5. SQL Server Audit state" -ForegroundColor Cyan
$audits = Invoke-Sqlcmd -ServerInstance "localhost" -Query "SELECT name, is_state_enabled FROM sys.server_audits"
if ($audits -and ($audits | Where-Object is_state_enabled -eq $true)) {
Write-Host " PASS: at least one Server Audit is running" -ForegroundColor Green
$audits | ForEach-Object { Write-Host " - $($_.name): enabled=$($_.is_state_enabled)" -ForegroundColor DarkGray }
} else {
Write-Host " FAIL: no Server Audit is enabled" -ForegroundColor Red
}
# 6. Audit specification covers login events
Write-Host "`n6. Audit specification action groups" -ForegroundColor Cyan
$auditSpecs = Invoke-Sqlcmd -ServerInstance "localhost" -Query @"
SELECT sasd.audit_action_name
FROM sys.server_audit_specifications sas
JOIN sys.server_audit_specification_details sasd ON sas.server_specification_id = sasd.server_specification_id
WHERE sas.is_state_enabled = 1
"@
$expected = @('FAILED_LOGIN_GROUP','SUCCESSFUL_LOGIN_GROUP','SERVER_ROLE_MEMBER_CHANGE_GROUP','SCHEMA_OBJECT_CHANGE_GROUP')
$captured = $auditSpecs.audit_action_name
$missing = $expected | Where-Object { $_ -notin $captured }
if (-not $missing) {
Write-Host " PASS: critical action groups captured" -ForegroundColor Green
} else {
Write-Host " WARN: action groups missing from audit:" -ForegroundColor Yellow
$missing | ForEach-Object { Write-Host " - $_" -ForegroundColor Yellow }
}
# 7. TCP/IP enabled at protocol level
Write-Host "`n7. SQL Server TCP/IP protocol" -ForegroundColor Cyan
$tcpKey = 'HKLM:\SOFTWARE\Microsoft\Microsoft SQL Server\MSSQL17.MSSQLSERVER\MSSQLServer\SuperSocketNetLib\Tcp'
$tcp = Get-ItemProperty -Path $tcpKey -ErrorAction SilentlyContinue
if ($tcp.Enabled -eq 1) {
Write-Host " PASS: TCP/IP is enabled" -ForegroundColor Green
} else {
Write-Host " FAIL: TCP/IP is DISABLED — remote clients cannot connect" -ForegroundColor Red
}
# 8. Firewall rule for 1433 has restricted scope
Write-Host "`n8. Firewall rule scope for TCP 1433" -ForegroundColor Cyan
$fwRules = Get-NetFirewallPortFilter -Protocol TCP |
Where-Object LocalPort -eq 1433 |
ForEach-Object { $_ | Get-NetFirewallRule | Where-Object { $_.Enabled -eq 'True' -and $_.Direction -eq 'Inbound' -and $_.Action -eq 'Allow' } }
if (-not $fwRules) {
Write-Host " WARN: no inbound Allow rule for TCP 1433 (remote clients will be blocked)" -ForegroundColor Yellow
} else {
foreach ($rule in $fwRules) {
$scope = ($rule | Get-NetFirewallAddressFilter).RemoteAddress
if ($scope -eq 'Any') {
Write-Host " FAIL: rule '$($rule.DisplayName)' allows TCP 1433 from ANY remote IP" -ForegroundColor Red
} else {
Write-Host " PASS: rule '$($rule.DisplayName)' scoped to $scope" -ForegroundColor Green
}
}
}
# 9. SQL Server certificate (is it self-signed or CA-issued?)
Write-Host "`n9. SQL Server TLS certificate" -ForegroundColor Cyan
$certThumb = (Get-ItemProperty -Path 'HKLM:\SOFTWARE\Microsoft\Microsoft SQL Server\MSSQL17.MSSQLSERVER\MSSQLServer\SuperSocketNetLib' -Name Certificate -ErrorAction SilentlyContinue).Certificate
if (-not $certThumb) {
Write-Host " WARN: no explicit certificate configured — SQL is using auto-generated self-signed cert" -ForegroundColor Yellow
Write-Host " Modern drivers reject self-signed by default. Replace with Let's Encrypt or internal CA." -ForegroundColor DarkGray
} else {
$cert = Get-ChildItem -Path Cert:\LocalMachine\My\$certThumb -ErrorAction SilentlyContinue
if ($cert.Issuer -eq $cert.Subject) {
Write-Host " WARN: configured certificate is self-signed (Issuer = Subject)" -ForegroundColor Yellow
} else {
Write-Host " PASS: certificate issued by external CA" -ForegroundColor Green
Write-Host " Issuer: $($cert.Issuer)" -ForegroundColor DarkGray
Write-Host " Subject: $($cert.Subject)" -ForegroundColor DarkGray
Write-Host " Expires: $($cert.NotAfter)" -ForegroundColor DarkGray
}
}
# 10. Recent failed login attempts (last 24h) — useful for spotting attacks
Write-Host "`n10. Failed logins in the last 24 hours" -ForegroundColor Cyan
try {
$failedLogins = Invoke-Sqlcmd -ServerInstance "localhost" -Query @"
SELECT
client_ip,
server_principal_name,
COUNT(*) AS FailedAttempts
FROM sys.fn_get_audit_file('C:\SQL\Audit\*.sqlaudit', DEFAULT, DEFAULT)
WHERE action_id = 'LGIF'
AND event_time > DATEADD(HOUR, -24, GETUTCDATE())
GROUP BY client_ip, server_principal_name
ORDER BY FailedAttempts DESC
"@ -ErrorAction Stop
if (-not $failedLogins) {
Write-Host " PASS: no failed login attempts in last 24 hours" -ForegroundColor Green
} else {
Write-Host " INFO: failed login activity detected:" -ForegroundColor Yellow
$failedLogins | Format-Table -AutoSize
}
} catch {
Write-Host " SKIP: audit log not readable (audit may not be enabled or path is wrong)" -ForegroundColor DarkGray
}
Write-Host "`n=== Verification complete ===" -ForegroundColor Cyan
Write-Host "Re-run weekly to detect drift.`n" -ForegroundColor DarkGray
How to use this script
For initial hardening: Run it once after completing every section in Part 2. Every check should return PASS. Any FAIL or WARN is a hardening gap to close.
For ongoing monitoring: Schedule it to run weekly via Task Scheduler, log output to a file, alert on FAIL lines:
powershell# Save the script as C:\SQL\Scripts\Verify-SQLHardening.ps1
# Then schedule it weekly:
$action = New-ScheduledTaskAction `
-Execute "PowerShell.exe" `
-Argument "-NoProfile -ExecutionPolicy Bypass -File C:\SQL\Scripts\Verify-SQLHardening.ps1 *> C:\SQL\Audit\hardening-check.log"
$trigger = New-ScheduledTaskTrigger -Weekly -DaysOfWeek Monday -At 6am
Register-ScheduledTask `
-TaskName "SQL Hardening Verification" `
-Action $action `
-Trigger $trigger `
-RunLevel Highest `
-User "SYSTEM"
For client handoff: Run it in front of the client, save the output as a PDF, attach to the runbook. Repeat it 30 days post-deployment to confirm nothing has drifted.
Tested on Raff
Tested on: SQL Server 2025 Standard Developer Edition (build 17.0.1000.7 RTM) on a Raff Windows Server 2025 — Production plan, 4 vCPU / 8 GB RAM / 120 GB NVMe, Windows Server 2025 Standard build 26100, Vint Hill, Virginia datacenter — on April 30, 2026, by Serdar Tekin. We verified end-to-end: sa login disable with audit-log capture of subsequent failed login attempt as evidence; least-privilege application login
app_raffapp01created with CHECK_POLICY/CHECK_EXPIRATION enabled, scoped to db_datareader/db_datawriter only, IsSysadmin: no; SQL Server AuditRaff_SecurityAuditwith 13 action groups including FAILED_LOGIN_GROUP / SUCCESSFUL_LOGIN_GROUP / SCHEMA_OBJECT_CHANGE_GROUP, file target atC:\SQL\Audit\, 100 MB max size with 10-file rollover, captured both LGIF and LGIS events with full metadata viasys.fn_get_audit_file; TCP/IP enabled at SuperSocketNetLib registry level (default state was DISABLED — significant finding for default-instance Custom installs); Windows Firewall rule for TCP 1433 with RemoteAddress scoped to203.0.113.10/32(RFC 5737 documentation IP — readers replace with their admin IP); xp_cmdshell verified at default off state. Certificate replacement (Let's Encrypt via win-acme) and TDE/backup encryption documented from Microsoft Learn references — not live-tested in this session due to test VM scope.
What's next
- Connect to your Raff Windows Server via RDP — first-step access guide
- Windows Server 2019 vs 2022 vs 2025 — Which to Choose — OS choice fundamentals
- RDS CAL Licensing on Windows Server — license multi-user RDP for DBAs and analysts
- Install SQL Server 2025 on Windows Server VPS — the install guide that precedes this hardening guide
- Self-Host QuickBooks Desktop Multi-User on Windows Server — QuickBooks runs on top of SQL Server; harden the underlying SQL the same way
Sources
- Microsoft Learn — Securing SQL Server
- Microsoft Community Hub — Secure by default: What's new in SQL Server 2025 security
- Microsoft Community Hub — TLS 1.3 in SQL Server 2025: What It Really Changes for Security
- Microsoft Learn — TLS 1.3 support
- Microsoft Learn — Configure TLS 1.3 on SQL Server 2022 and later
- Microsoft Learn — Encrypt connections by importing a certificate
- Microsoft Learn — Database Engine: Breaking Changes in SQL Server 2025
- Microsoft Learn — SQL Server Audit (Database Engine)
- Microsoft Learn — Server Audit Action Groups
- Microsoft Learn — CHECK_POLICY and CHECK_EXPIRATION login options
- Microsoft Learn — Transparent Data Encryption (TDE)
- Microsoft Learn — Backup Encryption
- Microsoft Learn — Connect to SQL Server when system administrators are locked out
- Microsoft Learn — SQL Server 2025 cumulative updates
- win-acme project — win-acme.com
- Date last verified: 2026-04-30
Microsoft, Windows Server, SQL Server, and SQL Server Management Studio are trademarks of Microsoft Corporation. Raff Technologies is an independent infrastructure provider and is not affiliated with, sponsored by, or endorsed by Microsoft Corporation.