9
Goal: secure by default patterns for App Service, Storage, SQL, Key Vault, and Cosmos DB.
Reference architecture
Internet
|
Azure Front Door (optional)
|
+---------App Service (WebApp)---------+
| inbound via Private EP |
| outbound via VNet integ |
+--------------------+------------------+
|
Spoke VNet
(subnets: app, pe, integ)
|
+---------------+----------------+
| |
Private Endpoint Private Endpoint
to Storage (blob) to Key Vault
| |
privatelink.blob.core privatelink.vaultcore.azure.net
.windows.net via hub via hub Private DNS Zones
Hub VNet
- Firewall or NVA (egress control)
- Private DNS Zones linked once per service
- DNS forwarders to 168.63.129.16
Spokes
- VNet pe subnet for PE NICs
- App subnet for runtime
- Integration subnet for regional VNet integration
Pattern library
Inbound to PaaS
Client -> Private DNS resolves privatelink FQDN -> PE NIC in pe subnet -> service
Notes: block public network access on target service. Require approved PE connection.
Outbound from App Service
WebApp -> VNet Integration (integ subnet) -> UDR to Azure Firewall -> allowlist to:
- Private Endpoint NICs for Storage, SQL, Key Vault, Cosmos
- Platform endpoints for identity, monitoring
Block all else. No direct Internet egress.
Hub and spoke Private DNS
[Private DNS Zones in Hub]
privatelink.blob.core.windows.net
privatelink.vaultcore.azure.net
privatelink.database.windows.net
privatelink.documents.azure.com
Link to Hub VNet only. Spokes resolve via Hub.
Bicep snippets
Private DNS zones and VNet link (Hub)
param hubVnetId string
resource dnsBlob 'Microsoft.Network/privateDnsZones@2018-09-01' = {
name: 'privatelink.blob.core.windows.net'
location: 'global'
}
resource dnsKv 'Microsoft.Network/privateDnsZones@2018-09-01' = {
name: 'privatelink.vaultcore.azure.net'
location: 'global'
}
resource dnsSql 'Microsoft.Network/privateDnsZones@2018-09-01' = {
name: 'privatelink.database.windows.net'
location: 'global'
}
resource dnsCosmos 'Microsoft.Network/privateDnsZones@2018-09-01' = {
name: 'privatelink.documents.azure.com'
location: 'global'
}
@batchSize(1)
module linkAll './modules/privateDnsLink.bicep' = [for z in [dnsBlob, dnsKv, dnsSql, dnsCosmos]: {
name: 'link-${z.name}'
params: {
zoneId: z.id
vnetId: hubVnetId
}
}]
modules/privateDnsLink.bicep:
param zoneId string
param vnetId string
resource link 'Microsoft.Network/privateDnsZones/virtualNetworkLinks@2018-09-01' = {
name: 'hub-link'
scope: resourceGroup()
parent: {
id: zoneId
}
location: 'global'
properties: {
registrationEnabled: false
virtualNetwork: { id: vnetId }
}
}
Private Endpoints for data services (Spoke)
param location string
param peSubnetId string
param storageId string
param keyVaultId string
resource peStorage 'Microsoft.Network/privateEndpoints@2023-09-01' = {
name: 'pe-storage-blob'
location: location
properties: {
subnet: { id: peSubnetId }
privateLinkServiceConnections: [
{
name: 'blob'
properties: {
groupIds: ['blob']
privateLinkServiceId: storageId
}
}
]
}
}
resource peStorageDns 'Microsoft.Network/privateEndpoints/privateDnsZoneGroups@2020-03-01' = {
name: 'dns'
parent: peStorage
properties: {
privateDnsZoneConfigs: [
{
name: 'blob'
properties: { privateDnsZoneId: resourceId('Microsoft.Network/privateDnsZones','privatelink.blob.core.windows.net') }
}
]
}
}
resource peKv 'Microsoft.Network/privateEndpoints@2023-09-01' = {
name: 'pe-kv'
location: location
properties: {
subnet: { id: peSubnetId }
privateLinkServiceConnections: [
{
name: 'vault'
properties: {
groupIds: ['vault']
privateLinkServiceId: keyVaultId
}
}
]
}
}
resource peKvDns 'Microsoft.Network/privateEndpoints/privateDnsZoneGroups@2020-03-01' = {
name: 'dns'
parent: peKv
properties: {
privateDnsZoneConfigs: [
{
name: 'vault'
properties: { privateDnsZoneId: resourceId('Microsoft.Network/privateDnsZones','privatelink.vaultcore.azure.net') }
}
]
}
}
Firewall and public access settings
param storageName string
param kvName string
resource stg 'Microsoft.Storage/storageAccounts@2023-01-01' existing = {
name: storageName
}
resource kv 'Microsoft.KeyVault/vaults@2023-07-01' existing = {
name: kvName
}
resource stgNet 'Microsoft.Storage/storageAccounts@2023-01-01' = {
name: storageName
location: stg.location
sku: { name: 'Standard_LRS' }
kind: 'StorageV2'
properties: {
publicNetworkAccess: 'Disabled'
minimumTlsVersion: 'TLS1_2'
}
}
resource kvNet 'Microsoft.KeyVault/vaults@2023-07-01' = {
name: kvName
location: kv.location
properties: {
tenantId: subscription().tenantId
sku: { family: 'A', name: 'standard' }
enableRbacAuthorization: true
publicNetworkAccess: 'Disabled'
networkAcls: {
defaultAction: 'Deny'
bypass: 'None'
}
}
}
End to end IaC example
Small but complete workload. One Web App with system assigned identity, VNet integration, PE to Storage and Key Vault, RBAC for identity.
param location string = resourceGroup().location
param spokeIntegSubnetId string
param peSubnetId string
// App Service Plan and WebApp with system assigned MI
resource plan 'Microsoft.Web/serverfarms@2022-09-01' = {
name: 'asp-secure'
location: location
sku: { name: 'P1v3', capacity: 1, tier: 'PremiumV3' }
}
resource web 'Microsoft.Web/sites@2023-12-01' = {
name: 'web-secure'
location: location
identity: { type: 'SystemAssigned' }
properties: {
serverFarmId: plan.id
httpsOnly: true
siteConfig: {
vnetRouteAllEnabled: true
ftpsState: 'Disabled'
ipSecurityRestrictionsDefaultAction: 'Deny'
}
publicNetworkAccess: 'Disabled'
}
}
// VNet integration
resource integ 'Microsoft.Web/sites/virtualNetworkConnections@2023-12-01' = {
name: 'vnet'
parent: web
properties: {
subnetResourceId: spokeIntegSubnetId
isSwift: true
}
}
// Data services
resource stg 'Microsoft.Storage/storageAccounts@2023-01-01' = {
name: 'stgsecure${uniqueString(resourceGroup().id)}'
location: location
sku: { name: 'Standard_LRS' }
kind: 'StorageV2'
properties: {
allowBlobPublicAccess: false
minimumTlsVersion: 'TLS1_2'
publicNetworkAccess: 'Disabled'
}
}
resource kv 'Microsoft.KeyVault/vaults@2023-07-01' = {
name: 'kv-secure'
location: location
properties: {
tenantId: subscription().tenantId
sku: { name: 'standard', family: 'A' }
enableRbacAuthorization: true
publicNetworkAccess: 'Disabled'
networkAcls: { defaultAction: 'Deny', bypass: 'None' }
}
}
// Private Endpoints
module pe './pe.bicep' = {
name: 'pe-all'
params: {
location: location
peSubnetId: peSubnetId
storageId: stg.id
keyVaultId: kv.id
}
}
// RBAC for Managed Identity
var miPrincipalId = web.identity.principalId
resource roleKv 'Microsoft.Authorization/roleAssignments@2022-04-01' = {
name: guid(kv.id, miPrincipalId, 'kv-secrets-user')
scope: kv
properties: {
roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions','4633458b-17de-408a-b874-0445c86b69e6') // Key Vault Secrets User
principalId: miPrincipalId
principalType: 'ServicePrincipal'
}
}
resource roleBlob 'Microsoft.Authorization/roleAssignments@2022-04-01' = {
name: guid(stg.id, miPrincipalId, 'stg-blob-data-contributor')
scope: stg
properties: {
roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions','ba92f5b4-2d11-453d-a403-e96b0029c9fe') // Storage Blob Data Contributor
principalId: miPrincipalId
principalType: 'ServicePrincipal'
}
}
pe.bicep is the snippet shown earlier.
Managed Identity patterns and code
When to use system assigned
- One to one relationship with the app
- Lifecycle tied to the app, no key rollover to manage
When to use user assigned
- Identity reused by many apps
- You want to rotate RBAC once, update many consumers
.NET examples using DefaultAzureCredential
Key Vault secret read:
using Azure.Identity;
using Azure.Security.KeyVault.Secrets;
var kvUrl = new Uri("https://kv-secure.vault.azure.net/");
var cred = new DefaultAzureCredential(); // set AZURE_CLIENT_ID for user assigned
var client = new SecretClient(kvUrl, cred);
var secret = await client.GetSecretAsync("DbPassword");
Blob write with MI:
using Azure.Identity;
using Azure.Storage.Blobs;
var blob = new BlobClient(new Uri("https://stgsecure.blob.core.windows.net/app/log.txt"), new DefaultAzureCredential());
await blob.UploadAsync(BinaryData.FromString("hello"), overwrite: true);
SQL access token:
using Azure.Core;
using Azure.Identity;
using Microsoft.Data.SqlClient;
var token = await new DefaultAzureCredential().GetTokenAsync(new TokenRequestContext(new[]{"https://database.windows.net/.default"}));
using var conn = new SqlConnection("Server=tcp:myserver.database.windows.net,1433;Database=mydb;Encrypt=True");
conn.AccessToken = token.Token;
await conn.OpenAsync();
User assigned MI in code:
var cred = new DefaultAzureCredential(new DefaultAzureCredentialOptions{ ManagedIdentityClientId = Environment.GetEnvironmentVariable("AZURE_CLIENT_ID") });
SQL contained user for the MI, run as AAD admin:
CREATE USER [web-secure] FROM EXTERNAL PROVIDER;
ALTER ROLE db_datareader ADD MEMBER [web-secure];
ALTER ROLE db_datawriter ADD MEMBER [web-secure];
Break glass and troubleshooting
Break glass runbook
1. Pager triggers. Incident commander approves elevation.
2. Elevate via PIM to built in roles with least privilege.
3. Create time bound firewall exception or temp PE in pe subnet.
4. Audit everything. Remove access when finished. File post incident review.
Troubleshooting flowchart
[Client cannot reach app]
|
v
[nslookup privatelink FQDN ok?] --no--> [Fix DNS: zone linked to hub? record exists?]
|
yes
|
v
[PE connection approved?] --no--> [Approve PE on resource]
|
yes
|
v
[Firewall set to Deny public?] --no--> [Disable public, rely on PE]
|
yes
|
v
[App outbound blocked?]
|
+--> [Check UDR to Firewall, allow tags for AzureMonitor, AzureActiveDirectory]
|
v
[MI call failing]
|
+--> [Check role assignments, token from IMDS, tenant trust]