Up

Upstood

Services

Company

Let's talk

All articles

AWS Tag Policies, Config Rules, and SCPs: What Each One Actually Enforces

5 August 2026 · 13 min read

Does an AWS tag policy enforce that your resources are tagged? No. It standardises the values of tags that are already there, and a resource created with no tags at all is not non-compliant. It is unevaluated.

The problem

Tagging usually gets treated as solved on the day a tag policy is attached in AWS Organizations. The policy is written, the compliance report comes back green or close to it, and attention moves on.

What the tag policy buys is real, and it is worth naming before saying what it misses. Tag keys are case sensitive, so CostCenter, costCenter and costcenter are three different tags, and three different lines in a cost report. A tag policy fixes the case treatment and the allowed values for a key. That is the difference between a cost allocation report that adds up and one that quietly splits a department across three rows.

It is also a narrower job than the name suggests. The AWS Organizations user guide states it directly (AWS Organizations user guide, "Tag policies", checked July 2026):

"Untagged resources or tags that aren't defined in the tag policy aren't evaluated for compliance with the tag policy."

The enforcement option does not close that gap. A tag policy can mark specified resource types as enforced, which means noncompliant tagging requests on those types are prevented from completing. A tagging request is an operation that attaches or changes a tag. Creating a resource and never tagging it is not a tagging request, so there is nothing for the policy to block.

The finding

Two gaps, and they compound in a specific way.

The first is that presence is never checked. A tag policy governs the shape of tags that exist. It has no opinion about a resource that has none.

The second is that the compliance report cannot show what it never evaluated. Resource Groups reports tag policy compliance, and a resource outside the policy's evaluation scope does not appear there as a failure. It does not appear at all.

Put those together and the result inverts what the report seems to say. Resources created through a pipeline are the ones most likely to carry tags, because the tags are written into the template once and applied on every deployment. Resources created by hand in the console, during an incident or a proof of concept, are the ones most likely to have none. So the resources most likely to be untagged are precisely the ones missing from the report that exists to find untagged resources. The report is not wrong. It is answering a narrower question than the one being asked of it.

This matters beyond reporting when tags are load-bearing. A common and reasonable position is that tags are the index and AWS is the database: the Resource Groups Tagging API, DescribeStacks, GetTemplate and Cost Explorer already answer what a separate inventory system would, and they reflect what exists rather than what was intended. That argument holds only while coverage is total. An index with holes is worse than one known to be partial, because the queries still return confident answers. "Which resources belong to this application" returns a list either way, and nothing in the response marks it as incomplete.

There is a second trap once policies start referencing tags. Tag keys are case sensitive on the resource, but condition key names in IAM policies are not (IAM user guide, "Controlling access to AWS resources using tags", checked July 2026):

"Key names are not case sensitive in policy conditions. This means that if you specify "aws:ResourceTag/TagKey1": "Value1" in the condition element of your policy, then the condition matches a resource tag key named either TagKey1 or tagkey1, but not both."

A resource can carry stack=production and Stack=test at the same time. A policy condition written against one of them matches one and not the other, and which one is not something the policy author chose.

The options

Six mechanisms. They sit at different points in the lifecycle, and several are detection rather than prevention.

Tag policies

Value standardisation across an organisation: allowed case treatment, allowed values, and optional enforcement of tagging operations on specified resource types. Requires AWS Organizations with all features enabled, and compliance is read through Resource Groups.

Covers the consistency problem completely and the presence problem not at all.

AWS Config required-tags

A managed rule that checks whether resources carry the tags you name. Its own documentation is explicit about what it is (AWS Config developer guide, required-tags, checked July 2026):

"this rule does not prevent you from creating resources with incorrect tags."

Two limits decide whether it fits. It accepts a maximum of six tag keys. And it supports 30 resource types, which as of July 2026 covers EC2 and its networking objects, RDS, Redshift, S3 buckets, DynamoDB tables, both load balancer generations, ACM certificates, CodeBuild projects, Auto Scaling groups and CloudFormation stacks. The list does not include Lambda, ECS, EKS, SQS, SNS, KMS, API Gateway, CloudFront, Secrets Manager or EFS. On a serverless or container estate, most of what runs is outside the rule's reach. The AWS-managed AWS-SetRequiredTags automation document also does not work as a remediation for it, so remediation means writing your own.

SCPs and IAM conditions on tag-on-create

The aws:RequestTag/<key> and aws:TagKeys condition keys evaluate the tags passed in a request, which makes it possible to deny a creation call that does not carry the required keys. This is the only mechanism here that stops an untagged resource from existing.

Coverage is per service and per action rather than universal. The IAM user guide is careful about this: "Some services allow users to specify tags when they create the resource if they have permissions to use the action that creates the resource." Whether a service supports tag-based access control at all is checked service by service, through the ABAC column of the services-that-work-with-IAM reference, and some services support only a service-specific condition key rather than the global one.

One detail is load-bearing when writing these. A ForAllValues condition on an empty set evaluates to true, so a policy requiring specific tag keys passes when the request carries no tags at all, which is the exact case it was written to catch. AWS's own example pairs it with a Null condition, which "ensures that the condition evaluates to false if there are no tags in the request".

CloudFormation hooks

Hooks run against a stack operation before it proceeds and can fail it. For an estate deployed entirely through CloudFormation, this puts the check at the deployment boundary rather than at the API call, which means one place to maintain instead of a policy per service. It says nothing about resources created outside CloudFormation.

Checks at plan or synth time

Whatever the infrastructure as code tool, the rendered template or plan can be inspected before it is applied and the build failed if a resource would be created untagged. This catches the problem earliest, with the clearest error message, in the pipeline, attributable to a commit, and it costs nothing to run.

Its boundary is exact: it sees only what that pipeline builds. It has nothing to say about the console.

It is also the only one of the six that can assert coverage rather than sample it, so the section after this one works it out in full, with the code.

Resource Explorer tag:none

Detection rather than enforcement, and it addresses the blind spot the compliance report has. The tag:none filter returns resources with no user-created tags attached, and AWS notes that "resources with AWS service-created tags still appear in results for this filter", so aws:cloudformation:stack-name on a managed resource does not hide it from the results.

Three limits before relying on it: tags attached to IAM resources cannot be used for searching, the view has to include tags as an IncludeProperty or tag filters throw a validation error, and a single query returns at most 1000 resources.

Closing it in code: apply the tags separately, then prove coverage at synth

Five of the six mechanisms above either block one API call or report on what already exists. Only the plan-time check answers "is every resource we deploy tagged" before the deployment happens, and it is usually described in a sentence and left there. Written out, the answer is a failed build rather than a report, and this is where the gap actually closes.

The premise is the one this article opened with: tags are the index, AWS is the database, no separate inventory. It holds only while coverage is total, so coverage is asserted rather than sampled. Two pieces do that. The tags are applied separately, in tiers, and a synth-time check proves the result.

The tags are applied separately, in two tiers

The app tier goes on at the entrypoint and covers the whole stack:

applyPlatformTags(app, { companyId, appId, environment, extra });

That emits three keys, each namespaced with the company prefix: a constant managed, the application identifier, and the environment.

The component tier cannot go there. A stack holds several components, built from different reusable blocks at different versions, so at stack level there is no single block and no single version to state. Each component tags itself instead, inside its own constructor:

constructor(scope: Construct, id: string, props: S3BucketProps) {
  super(scope, id);

  // The component tags itself. An app stack composes several of these, and a
  // component that relied on its caller to say which block built it would
  // eventually meet a caller that forgot.
  applyComponentTags(this, {
    companyId: props.companyId,
    block: "s3",
    blockRef: props.blockRef,
    role: props.role,
  });

That split is the point. Tagging from the caller is a convention, and a convention is kept by whoever remembers it. Tagging from inside the construct means a component cannot be added to a stack and arrive unlabelled, because the labelling is part of constructing it.

managed is worth separating from the rest. It is a constant, never an input, and it is the only key that classifies rather than describes. It is what answers the question the compliance report cannot: which resources in this account did not come from the platform at all.

The check that turns it into a guarantee

Applying tags is not the same as having them. An aspect walks the synthesized tree and fails the build on any resource missing a required key:

export class RequiredTagsAspect implements IAspect {
  private readonly required: string[];

  constructor(companyId: string) {
    this.required = PLATFORM_KEYS.map((key) => `${companyId}:${key}`);
  }

  public visit(node: IConstruct): void {
    // Only template resources. A Stack is taggable too, but it holds MANY
    // components and so has no single block or ref: asserting the component
    // tier on it would demand a value that cannot exist.
    if (!CfnResource.isCfnResource(node)) {
      return;
    }

    // Both flavours, the way CDK's own Tag.visit does it. Checking only one
    // silently skips part of the tree.
    const manager = TagManager.isTaggableV2(node)
      ? node.cdkTagManager
      : TagManager.isTaggable(node)
        ? node.tags
        : undefined;

    if (!manager) {
      return;
    }

    const present = manager.tagValues();
    const missing = this.required.filter((key) => present[key] === undefined);

    if (missing.length > 0) {
      Annotations.of(node).addError(`Missing required tag(s): ${missing.join(", ")}`);
    }
  }
}

This does what input validation cannot. Validating configuration catches a bad key that was supplied. It cannot catch a resource that ends up untagged anyway, because the tagging call skips non-taggable nodes in total silence: no error, no warning, nothing in the output marking that a node was passed over. Reading the rendered template back is what converts that silence into a failed build.

Two details in there were learned rather than designed.

The first is the two TagManager flavours. Some L1 resources are taggable the v1 way and others the v2 way, and both turn up in the same synth. Checking only one skips part of the tree without saying so, which produces the exact false confidence this article is about, one layer further down.

The second is registration order:

Aspects.of(app).add(new RequiredTagsAspect(companyId), {
  priority: AspectPriority.READONLY,
});

That priority is load-bearing. Tag aspects register at the default priority, and an inspection registered at the same priority runs against a tree where the tags have not landed yet. The check then passes on an empty result, which is the worst available outcome for a control whose entire job is proving something is present.

What it catches in practice

The strictness has knock-on effects, and they are the useful kind: it fails on constructions that were already quietly wrong. Publishing a component's outputs to Parameter Store creates parameters, and a parameter created directly in the stack's scope inherits the app tier but no component tier, because the stack has no single block to inherit from. The aspect refuses the synth. The fix is a container scope tagged as the component it belongs to, and the point is that this surfaced as a failing build rather than as an incomplete report six months later.

The other trap the code closes is last write wins. Configuration may supply extra keys, and they are checked against both a naming pattern and a reserved list, throwing rather than warning. Tag application is last write wins and the configuration loop runs after the platform keys, so a per-environment file supplying a key the platform already emits would replace a per-request value with a per-environment constant. The resource would still be fully tagged. It would simply carry a confident wrong answer.

What was deliberately left out matters as much. An identifier tying a resource back to the request that created it was considered and rejected: it rewrites on every redeployment, so it records the most recent requester rather than the original, and high-churn tag values fill change sets with noise that hides real diffs while some resource types replace rather than update when a tag changes. Commit identifiers, build numbers and timestamps fail the same test. Anything CloudFormation already applies is excluded because duplicating it buys nothing.

The boundary

It sees only what that pipeline builds. A resource created by hand in the console has never been through it and never will be, and that is the population this article identified as most likely to be untagged in the first place.

So it converts one specific question. "Are the resources we deploy tagged" stops being something to audit and becomes something that cannot compile. "What else is in this account" is untouched, and still belongs to the detection mechanisms above. Neither does anything for cost until the keys are activated as cost allocation tags.

Where each one fits

How resources get created decides most of it. An estate where everything arrives through a pipeline can put the check in the pipeline and get the earliest and clearest failure available, with nothing further required. The moment a person can open the console, that check stops being complete, and the question becomes whether the remaining gap is closed by prevention at the API or by detection afterwards.

What the tags are for decides how much the gap costs. Tags funding a cost allocation report tolerate discovery on a monthly cycle, because the report is read monthly and a missing resource surfaces as unallocated spend rather than as a wrong answer. Tags answering an audit question, where "show us everything in scope for this system" has to be complete on the day it is asked, are a different matter. There, the difference between an incomplete list and a list marked incomplete is the entire answer.

Which services are in the account decides whether the Config rule is a control or a fragment of one. Thirty resource types is substantial coverage for an EC2 and RDS estate and thin coverage for a Lambda and ECS one. That is a factual question about a specific account, and the answer changes what the same rule is worth.

Prevention and detection also do not have to be applied uniformly. Denying creation without tags in a production account has a bounded blast radius, because production changes usually go through a pipeline that can be fixed once and then keeps working. The same denial in a development account tends to produce workarounds rather than tags, and infrastructure that exists outside the mechanism entirely is a larger gap than infrastructure that is merely untagged. Detection everywhere with prevention where the stakes justify the friction is one way that lands. Uniform enforcement is another, and which fits depends on how the teams involved actually work.

One last thing sits outside all of it. None of these mechanisms make tags usable for cost until the keys are activated as cost allocation tags in the billing console, which only the payer account can do and which takes days rather than minutes to appear in Cost Explorer. A perfectly enforced tag that was never activated produces no cost data at all.


Want us to look for issues like this in your account? We offer a free AWS audit: upstood.com

Want this looked at in your AWS account?

Every audit covers cost, security and architecture design at a fixed price, with a guarantee: results or you do not pay

See audit tiers

Upstood

Home
Let's talk
Upstood | 2026

VAT: IT14214770969 · Via Cufra 17 · 20159 Milano - Italia · Tel: +39 3447504971 · info@upstood.com