Secure by Default: AAD Only, Private Endpoints, and Auditing for Azure SQL
A zero-trust, least-privilege checklist you can actually run
Introduction - why this matters
You don't get hacked by what you don't know; you get hacked by what you left "temporarily" open. Azure SQL is powerful, but its defaults still need hardening. If you want zero-trust and least-privilege by design, lock identity to Entra ID (AAD) only, remove public network paths with Private Endpoints, and wire full-fidelity auditing to immutable storage and your SIEM. This checklist shows the exact switches, scripts, and tests.
Architecture at a glance
Goal: eliminate passwords, eliminate public ingress, and make every action observable.
- Identity: AAD-only (no SQL logins), managed identities for apps, role-based least privilege.
- Network: Public network disabled, traffic over Private Endpoint in your VNet with strict NSG/UDR.
- Data plane controls: Encryption (TDE on, CMK optional), Ledger (optional), minimal surface area.
- Visibility: Auditing to Storage (immutable), Log Analytics for hunting, Defender for Cloud alerts.
Fast checklist (copy/paste friendly)
1) Enforce AAD-only authentication
Why: kill passwords; unify lifecycle and MFA/Conditional Access.
CLI (preview flags sometimes change; verify in your subscription):
# Set Azure SQL logical server to AAD-only (no SQL logins)
az sql server update \
-g <rg> -n <server-name> \
--restrict-azure-services-access false \
--enable-ad-only-auth true
# Make an AAD group the Azure AD admin (not a single user)
az sql server ad-admin create \
-g <rg> -s <server-name> \
--display-name "grp-sql-admins" \
--object-id <aad_group_object_id>
T-SQL (run as an AAD admin) - grant minimal roles:
-- Create contained AAD users at the database level
CREATE USER [app-mi] FROM EXTERNAL PROVIDER; -- managed identity
ALTER ROLE db_datareader ADD MEMBER [app-mi]; -- example: read only
-- Avoid db_owner; prefer explicit GRANTs or least-privilege roles
Policy guardrail (Azure Policy):
- Deny: SQL servers without AAD-only enabled.
- Deny: SQL servers with public network access enabled.
- Audit/Deny: databases without auditing to approved destinations.
2) Remove public ingress with Private Endpoints
Why: public endpoints are the easiest pivot for attackers.
Disable public network access on the server:
az sql server update -g <rg> -n <server-name> --public-network-access Disabled
Create Private Endpoint into your spoke VNet:
# Private DNS zone for privatelink.database.windows.net
az network private-dns zone create -g <rg-net> -n privatelink.database.windows.net
# Link DNS zone to your VNet
az network private-dns link vnet create \
-g <rg-net> -n link-sql-privdns \
-z privatelink.database.windows.net \
-v <vnet-name> -e true
# Create the Private Endpoint
az network private-endpoint create \
-g <rg-net> -n pe-sql-<server-name> \
--vnet-name <vnet-name> --subnet <subnet-name> \
--private-connection-resource-id $(az sql server show -g <rg> -n <server-name> --query id -o tsv) \
--group-id sqlServer \
--connection-name pe-sql-conn
# DNS record auto-registered if using the built-in DNS zone group;
# otherwise add A record for <server-name>.privatelink.database.windows.net
Validate path:
nslookup <server-name>.database.windows.net
# Expect CNAME -> <server-name>.privatelink.database.windows.net -> Private IP
NSG/UDR guidance:
- No inbound from 0.0.0.0/0; allow only required subnets (app tiers, jump boxes).
- Force egress inspection if you run a firewall (Azure Firewall/NGFW) - but don't break DNS.
3) Turn on Auditing (immutable + SIEM) and Threat Detection
Why: if you can't see it, you can't defend it (or prove it).
Destination options (pick at least 2: one immutable, one queryable):
| Destination | Purpose | Pros | Cons |
|---|---|---|---|
| Storage (Blob) | Immutable, cheap, retention-friendly | Legal hold, WORM | Harder to query at scale |
| Log Analytics | SIEM hunting, KQL, alerts | Fast queries, Defender integration | Cost requires tuning |
| Event Hubs | Stream to 3rd-party SIEM | Vendor-neutral pipeline | More moving parts |
CLI - server-level auditing to Storage + Log Analytics:
# Storage
az sql server audit-policy update \
-g <rg> -s <server-name> \
--state Enabled \
--storage-account <storage-name> \
--retention-days 365
# Log Analytics workspace
az monitor diagnostic-settings create \
--name "sql-diag" \
--resource $(az sql server show -g <rg> -n <server-name> --query id -o tsv) \
--workspace <log-analytics-id> \
--logs '[{"category":"SQLSecurityAuditEvents","enabled":true}]'
Enable Microsoft Defender for SQL (threat detection):
# Plan on the server (or at subscription scope)
az security pricing create --name SqlServers --tier Standard
# Optional email notifications
az sql server threat-policy update \
-g <rg> -s <server-name> \
--state Enabled --email-account-admins Enabled \
--email-addresses secops@example.com
KQL starter (failed logins spike):
SQLSecurityAuditEvents
| where action_name == "DATABASE AUTHENTICATION FAILED"
| summarize failures = count() by bin(TimeGenerated, 5m), client_ip
| order by failures desc
Hardening details & code you'll actually use
Identity & access (least privilege)
- Prefer group-based AAD admins over individuals.
- Use contained AAD users in databases, with explicit GRANTs.
- Managed Identities for apps; never store connection strings with secrets.
- Kill legacy:
DROP LOGINfor leftover SQL logins after AAD-only is on.
T-SQL pattern:
-- Example app role with minimal perms
CREATE ROLE app_reader;
GRANT SELECT ON SCHEMA::dbo TO app_reader;
ALTER ROLE app_reader ADD MEMBER [app-mi]; -- AAD MI principal
Network - make "public" impossible
- Public network access = Disabled at the server.
- One Private Endpoint per region; avoid hairpin across regions.
- Lock down Approved subnet list as code (Bicep/Terraform).
- Use Private DNS zones; avoid custom host files.
Auditing & retention
- 365-730 days in Blob with immutability policy if you have compliance needs.
- Log Analytics tables to watch:
SQLSecurityAuditEvents,AzureDiagnostics. - Data volume controls: filter noisy categories or reduce sampling intervals thoughtfully.
Data protection
- TDE default On; consider Customer-Managed Key (CMK) if required.
- Always Encrypted for client-side secrets; Dynamic Data Masking for analysts.
- Least-privileged procs/views > direct table access.
Operations as code
- Express all of the above with ARM/Bicep/Terraform + Azure Policy.
- Add Pester/pytest checks that resolve DNS to
privatelink.*and verify--enable-ad-only-auth.
Verification tests (don't skip)
- Password attempts should fail: try
sqlcmdwith SQL auth - expect failure. - Public egress blocked: from a random VM on the internet,
telnet <server>.database.windows.net 1433- no route. - Private DNS correct: CNAME ->
privatelink.database.windows.net, A -> 10.x address. - Audit events present: run a failed login; confirm event in Storage and Log Analytics within minutes.
- Role scoping works: the app MI can read what it should - and nothing else.
Common pitfalls (blunt truths)
- "Temporary" public access becomes permanent. Don't do it - use a jump host in the VNet.
- Admin by person, not group: you will lock yourself out when that person leaves.
- Forgetting DNS: Private Endpoint without Private DNS = random timeouts and "works on my machine."
- Over-granting
db_owner: laziness today, breach path tomorrow. Create roles. - Auditing to SIEM only: keep immutable Storage for investigations.
- Secrets in app settings: use Managed Identity + AAD auth, period.
Example Bicep (snippet)
param serverName string
param rgLocation string
param logAnalyticsId string
param storageId string
resource sql 'Microsoft.Sql/servers@2022-05-01-preview' = {
name: serverName
location: rgLocation
properties: {
publicNetworkAccess: 'Disabled'
administratorLogin: '' // none; AAD-only
restrictOutboundNetworkAccess: 'Enabled'
azureADOnlyAuthentication: { azureADOnlyAuthentication: true }
}
}
resource diag 'Microsoft.Insights/diagnosticSettings@2021-05-01-preview' = {
name: 'sql-diag'
scope: sql
properties: {
workspaceId: logAnalyticsId
logs: [
{ category: 'SQLSecurityAuditEvents', enabled: true }
]
}
}
resource aud 'Microsoft.Sql/servers/auditingSettings@2021-11-01' = {
name: '${serverName}/default'
properties: {
state: 'Enabled'
isAzureMonitorTargetEnabled: true
storageEndpoint: reference(storageId, '2021-09-01', 'Full').primaryEndpoints.blob
retentionDays: 365
}
}
Internal link ideas (for your site)
- "Managed Identities with Azure SQL: End-to-End App Auth"
- "Designing Private DNS for Azure PaaS: Gotchas and Patterns"
- "KQL Playbook: Hunting SQL Auth Anomalies in Log Analytics"
- "Terraform Module: AAD-Only Azure SQL with Private Endpoints"
Conclusion & Takeaways
Zero trust isn't a slogan; it's a set of defaults you refuse to compromise: AAD-only, no public ingress, full auditing. Put these controls in code, verify them continuously, and don't hand out permissions you can't justify. If something breaks because of these settings, fix the app - don't weaken the guardrails.
Bottom line checklist
- AAD-only enabled; no SQL logins exist
- Public network access disabled
- Private Endpoint + Private DNS configured and tested
- Auditing to Storage (immutable) + Log Analytics
- Defender for SQL alerts on and routed to SecOps
- Least-privilege roles; no
db_ownerfor apps - IaC + Policy enforcing all of the above
Image prompt
"A clean, modern diagram of an Azure SQL hardening architecture: AAD-only authentication, disabled public endpoint, Private Endpoint to a VNet with Private DNS zone, auditing flowing to Blob Storage (immutable) and Log Analytics workspace, with a zero-trust lock icon - minimalistic, high contrast, isometric style."
Tags
#AzureSQL #ZeroTrust #LeastPrivilege #PrivateEndpoint #AzureAD #Auditing #DefenderForCloud #DataSecurity #CloudArchitecture #DevSecOps



Leave a Comment