Safeguard
Cloud Security

Azure Bicep IaC security fundamentals: secrets, module trust, and policy gates

A @secure() Bicep parameter still leaks in plaintext the moment it's written to an output — a well-documented gap most teams discover only after a deployment history review.

Safeguard Research Team
Research
Updated 7 min read

Azure Bicep has replaced hand-written ARM JSON as the default way most teams author Azure infrastructure, and it ships a decorator, @secure(), specifically to keep passwords and connection strings out of deployment logs. Applied to a string or object parameter, @secure() stops ARM from writing that value to the Azure portal, CLI, or PowerShell output during a deployment. For a long stretch of Bicep's history, that protection stopped at the parameter boundary: once a secure value flowed into an output, it was written to deployment history in plaintext, and anyone with read access to that deployment, including collaborators with only reader-level RBAC on the resource group, could retrieve it. Bicep now lets you apply the same @secure() decorator directly to string and object outputs, so a masked value never has to leave the parameter side of a module in the first place — but the mechanism is opt-in per output, so a template author who forgets to add the decorator still ships a plaintext leak. This is one of three recurring, well-documented IaC security risk areas in Bicep-based infrastructure as code: secret handling that looks safe but isn't, module trust that assumes upstream references never change, and validation that only happens after az deployment group create has already run. This piece walks through each, plus the two real, actively maintained tools — ARM's native what-if operation and the Microsoft-maintained PSRule for Azure — that let a team catch misconfigurations before they ever hit a live Azure subscription.

Why isn't @secure() enough to protect a Bicep secret end to end?

@secure() protects a parameter's value from appearing in deployment logs, portal history, and CLI/PowerShell console output at the point of input. For a long time, Microsoft Learn's Bicep documentation was explicit that this protection did not carry through to output statements: if a module took a @secure() string parameter and then declared output dbConnectionString string = connectionString for convenience — often done so a parent template can consume a generated value — that output was written to the deployment's activity history unmasked, regardless of how the value entered the template. Bicep has since closed most of that gap by extending @secure() to string and object outputs directly, so @secure() output dbConnectionString string = connectionString is now masked in deployment history the same way a secure parameter is. The residual risk is that the decorator on an output is opt-in and easy to skip: engineers see @secure() on the parameter side, assume the whole data flow is protected, and never audit whether the corresponding outputs block carries the same decorator. The fix is still procedural as much as technical — treat any output block as a place that needs its own @secure() review, not an inheritance from the parameter it's derived from, and prefer not returning a secret through an output at all when a Key Vault reference will do.

What's the recommended pattern for keeping secrets out of parameter files entirely?

The pattern Microsoft documents avoids the problem at the source: never put a secret literal in a .bicepparam file or a checked-in parameters.json at all. .bicepparam files can call the built-in getSecret() function, referencing Key Vault directly (via az.getSecret() syntax) so the value is resolved by ARM at deployment time and never touches source control, CI logs, or a developer's terminal history in plaintext. The equivalent module-level pattern uses a resource keyVault 'Microsoft.KeyVault/vaults@...' existing reference combined with keyVault.getSecret('secretName') passed as a secure parameter to a nested deployment. Both approaches share the same principle: the secret's only representation in the repository is a Key Vault resource ID and secret name — public, unremarkable metadata — while the actual value is fetched just-in-time by Azure Resource Manager under the deploying identity's RBAC permissions. This also means secret rotation happens in Key Vault, not by editing and re-committing a parameter file, which closes off an entire class of "old secret still in git history" incidents.

How does Bicep module trust create the same risk as an unpinned dependency?

Bicep modules can be sourced from local files, a private or public Bicep registry — which is OCI-based, typically hosted on Azure Container Registry — or a Template Spec, and in every registry case the module is resolved by reference at deployment time, not vendored into your repository. That's structurally identical to the risk profile of an unpinned npm package or an unpinned Terraform module: if a module reference points to a mutable tag like br:myregistry.azurecr.io/bicep/modules/network:v1 rather than an immutable digest, whoever controls that registry path can change what gets pulled on your next deployment without your .bicep file changing at all. The standard mitigations mirror software supply chain practice elsewhere: pin module references to a content digest instead of a floating tag, and restrict which registries a CI pipeline's service principal is permitted to pull from in the first place, so a typo'd or compromised registry path can't silently resolve to attacker-controlled infrastructure code with the same deployment privileges as the rest of the template.

What's the difference between what-if and policy-as-code validation before deployment?

az deployment group what-if (and its subscription/tenant-scope equivalents) is an ARM-native operation that predicts exactly which resources a deployment will create, modify, or delete, without applying any of those changes — it's a dry run against live Azure state, run before az deployment group create. It answers "what will this deployment actually do," but it doesn't know whether what it's about to do is a good idea. That's the job of static policy-as-code tooling like PSRule for Azure (Azure/PSRule.Rules.Azure on GitHub, maintained by Microsoft), which performs pre-flight analysis of Bicep or ARM JSON source directly — before any deployment call is made — against curated rule sets aligned to the Cloud Adoption Framework and the Well-Architected Framework. PSRule for Azure ships documented quickstarts for both Azure Pipelines and GitHub Actions, letting a CI job fail a pull request when a template defines, for example, a storage account without HTTPS-only enforced. This is meaningfully different from Azure Policy, which evaluates resources after deployment (or at deployment time via deny-effect policies) — PSRule catches the same class of misconfiguration earlier, against source code, before a deployment credential is ever invoked.

What should a Bicep IaC security checklist actually enforce in CI?

A practical checklist gates on all three fundamentals rather than treating them as separate concerns owned by different teams. First, block any pull request where a template diff introduces a new output referencing a @secure() parameter or a value derived from one, unless that output is itself decorated with @secure() — this is a pattern static analysis of the Bicep AST can catch mechanically, since it doesn't require running the deployment. Second, enforce that module references resolve to digests, not tags, and maintain an allow-list of registries the deployment pipeline's identity can pull from — a policy PSRule for Azure or a custom pre-commit check can both express. Third, run what-if and PSRule for Azure as sequential, mandatory CI gates: PSRule against the source before any Azure credential is used, what-if against the target subscription immediately before the real deployment, so the last thing a pipeline confirms is that the predicted change set still matches intent. None of these three checks is exotic tooling — all three are documented, Microsoft-maintained or Microsoft-native capabilities — which is exactly why teams that skip them are choosing to skip something that was already available, not something they had to build.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.