A while back I spoke at the Cloudbrew conference in Belgium on Seriously securing an Azure PaaS application. You can find the sample app I showed on GitHub. In this article I'd like to take a look at one part of the presentation in particular: the "good enough" app architecture.
Architecture
These are the components we will be using:
The total cost at the time of making this diagram was 579€/month. It does not use the cheapest SKU of every component as I wouldn't really recommend those for production scenarios.
Let's look at each of the components in detail.
Entra ID
Formerly known as Azure Active Directory, Entra ID gives us a secure OpenID Connect identity provider. Users authenticate with their accounts there, and our app never sees the user passwords etc. Instead of passwords, users can also use passwordless authentication with Microsoft Authenticator or use a passkey.
If you just need authentication, it'll cost you nothing (other than the work of course).
Other identity providers exist of course. In general I recommend using an existing external identity provider; it takes a large chunk of responsibility away from your hands.
In the sample app, we create an authentication cookie for the user after they authenticate with Entra ID.
Web Application Firewall
Most web applications can benefit from a Web Application Fireall (WAF). It can block common attacks, scanners, and bots. It's never perfect, but it can be worth it making attacking your app require more effort. On Azure, you can use either App Gateway or Front Door to add a WAF.
App Gateway is a regional service and acts as a layer 7 load balancer. That means it can do for example request routing based on the URL path.
Front Door on the other hand is a global service that can route requests to the closest region in which your app is deployed.
A downside of a WAF is the false positives. It'll detect an SQL injection in an authentication cookie because the base64 encoding happened to put "--" in there. So you need time to tweak the rules such that your app actually works. Unfortunately it often leads people to disable rules.
You also get some complexity for handling custom domains, TLS etc. Expecially if you want end-to-end TLS, since App Gateway and Front Door terminate the connection and make a separate request to your back-end.
An App Gateway with a WAF costed 325€/month when I made the diagram, so it is a large chunk of the total cost.
Managed Identity
This is actually one of my absolute favourite features in Azure. You enable a Managed Identity for a service and now it has an Entra ID service principal that can be used without any keys or secrets in your app. So we can access Azure services like SQL Database, Storage and Key Vault without storing any secrets in the app. You can also use it to access any other service, as long as it supports Entra ID JWT authentication with application permissions.
It also costs nothing. I use it in literally every application.
Key Vault + Storage for cookie encryption keys
In the sample application, we use ASP.NET Core Data Protection to encrypt our authentication cookies. By default in App Service, these keys would be stored unencrypted on the App Service file system. It does this by default so that all instances of the App Service can access the same keys and it doesn't matter which instance the user hits. To do this better, we can store the keys in Azure Storage, encrypted with a key in Key Vault.
More specifically, the generated cookie encryption keys are encrypted with a randomly generated AES key. Then the AES key is encrypted (or wrapped) with an RSA key in Key Vault. The encrypted AES key is stored with the encrypted keys in Azure Storage. So you can't access the actual keys without having access to both Storage and Key Vault.
Configuring this in ASP.NET Core is very easy:
var tokenCredential = new ManagedIdentityCredential();
builder.Services.AddDataProtection()
.PersistKeysToAzureBlobStorage(new Uri(builder.Configuration["DataProtection:StorageBlobUri"]), tokenCredential)
.ProtectKeysWithAzureKeyVault(new Uri(builder.Configuration["DataProtection:KeyVaultKeyUri"]), tokenCredential);
This uses these two libraries: Azure.Extensions.AspNetCore.DataProtection.Blobs and Azure.Extensions.AspNetCore.DataProtection.Keys.
The key has to exist in Key Vault, and the blob container in Storage has to be created. The blob does not need to exist, it'll be created by the app. But other than that, you just tell it "use this key and put the keys here and use this credential".
The cost of this is very small, less than 1€/month.
Private endpoint for the database
Private endpoints in Azure allow you to have a private IP address for a service that is accessible from a virtual network. This allows usage of e.g. Network Security Groups to lock down access to the service.
In my recent projects, I've had to use private endpoints quite a bit. I don't recommend every app to use private endpoints for everything. It's a bit too much of a pain vs the benefits. However, I would recommend it for data stores of personal or sensitive data. So in the good enough architecture, we use it for the Azure SQL Database.
To use private endpoints, we also need a virtual network and an Azure DNS zone. The App Service also needs to be connected to the virtual network with VNET Integration. You'll need at least two subnets, one for the App Service and one for the private endpoint.
One "fun" thing to know about private endpoints is that Azure Storage is special. You need a private endpoint and DNS zone for each service in the account: blobs/files/queues/tables/adls/static website. If you need just e.g. blobs, you can create just one endpoint.
Application Insights/Azure Monitor is also special, since it uses global/regional domain names instead of instance-specific domain names. So you access all of your Application Insights instances through an Azure Monitor Private Link Scope (AMPLS). Then you can add private endpoints to it.
The downsides of private endpoints in general are the increased complexity and having to deal with DNS.
The cost is a little under 10€/month for one endpoint.
Firewall restrictions
In general, it's a good idea to restrict addresses that can connect to your services. The front-end that your users hit may be one where you can't restrict it a lot, but back-end services like Storage accounts don't have to be open to the world. In App Service, for example, it'll have a set of outbound IP addresses. You can add those to your data stores and reduce the attack surface substantially. Now of course in App Service the outbound addresses are shared on the "stamp". So some other customers may use the same addresses. But when you add the authentication layer on top of this, it's secure enough for a lot of cases in my opinion.
Never enable the "Allow Azure services and resources to access this server" on Azure SQL. This does not allow only the services in your subscription access, it adds all Azure IP addresses as allowed addresses.
In our sample app, we use public Azure DevOps build agents for deployments. These agents have essentially random IP addresses, so we will have to dynamically add the address to allowed addresses when running the deployment (and of course remove it once we are done).
Firewall restrictions cost nothing, just the work.
Security headers
Since our application is a web application, we can increase client-side security significantly by implementing security headers.
List of useful headers in no particular order:
- Strict-Transport-Security (HSTS): always HTTPS
- Content-Security-Policy (CSP): restrict sources of e.g. Javascript
- CORS: which other domains can use this app?
- Referrer-Policy: control referrer info
- X-Content-Type-Options: prevent MIME sniffing
- Cross-Origin-Resource-Policy (CORP): block cross-origin requests for e.g. images
- Permissions-Policy: restrict device features that can be used
Content-Security-Policy (CSP) in particular kills a lot of XSS attacks. But it can sometimes be a bit of a pain for development as you need to continuously keep it up to date.
Security headers cost nothing, implement them. Especially HSTS and CSP.
The small settings
There are a bunch of small settings on Azure services that can also improve security.
Here are some I'd recommend:
- Require HTTPS (redirect HTTP to HTTPS)
- Minimum TLS 1.2 (+ allowed ciphers)
- Don't allow blob public access
- No FTP deployment
- Allow only Entra ID authentication
None of this costs anything other than the work.
Disclaimer
What is "good enough" of course depends on your particular use case and context. I always recommend doing threat modeling to figure out what mitigations need to be applied. To give an example, Azure DDoS Protection can be pretty expensive. But if the cost of a DDoS attack is higher, and an attack is somewhat likely, it can be very much worth it to prepare.