<?xml version="1.0" encoding="UTF-8"?><rss version="2.0"
	xmlns:content="http://purl.org/rss/1.0/modules/content/"
	xmlns:wfw="http://wellformedweb.org/CommentAPI/"
	xmlns:dc="http://purl.org/dc/elements/1.1/"
	xmlns:atom="http://www.w3.org/2005/Atom"
	xmlns:sy="http://purl.org/rss/1.0/modules/syndication/"
	xmlns:slash="http://purl.org/rss/1.0/modules/slash/"
	>

<channel>
	<title>Multi-Account Strategy | John Nessime</title>
	<atom:link href="https://john-nessime.com/blog/tag/multi-account-strategy/feed/" rel="self" type="application/rss+xml" />
	<link>https://john-nessime.com/blog/tag/multi-account-strategy/</link>
	<description>Cloud, DevOps, Data &#38; AI — Built, Tested, Explained</description>
	<lastBuildDate>Sun, 20 Sep 2026 12:25:50 +0000</lastBuildDate>
	<language>en-US</language>
	<sy:updatePeriod>
	hourly	</sy:updatePeriod>
	<sy:updateFrequency>
	1	</sy:updateFrequency>
	<generator>https://wordpress.org/?v=7.1.3</generator>

<image>
	<url>https://john-nessime.com/blog/wp-content/uploads/2026/07/cropped-jn-32x32.png</url>
	<title>Multi-Account Strategy | John Nessime</title>
	<link>https://john-nessime.com/blog/tag/multi-account-strategy/</link>
	<width>32</width>
	<height>32</height>
</image> 
	<item>
		<title>AWS Cost Allocation Tags: Getting Real Coverage on a Messy Bill</title>
		<link>https://john-nessime.com/blog/technical-guides/aws-cost-allocation-tags-coverage/</link>
		
		<dc:creator><![CDATA[John Nessime]]></dc:creator>
		<pubDate>Tue, 15 Sep 2026 06:00:00 +0000</pubDate>
				<category><![CDATA[Cloud Computing]]></category>
		<category><![CDATA[FinOps]]></category>
		<category><![CDATA[Technical Guides]]></category>
		<category><![CDATA[Amazon Athena]]></category>
		<category><![CDATA[AWS]]></category>
		<category><![CDATA[AWS Config]]></category>
		<category><![CDATA[AWS Organizations]]></category>
		<category><![CDATA[Backfill]]></category>
		<category><![CDATA[Cost Allocation Tags]]></category>
		<category><![CDATA[Cost and Usage Report]]></category>
		<category><![CDATA[Cost Explorer]]></category>
		<category><![CDATA[Cost Optimization]]></category>
		<category><![CDATA[Multi-Account Strategy]]></category>
		<category><![CDATA[Service Control Policies]]></category>
		<category><![CDATA[Showback]]></category>
		<category><![CDATA[Tag Policies]]></category>
		<category><![CDATA[Terraform]]></category>
		<guid isPermaLink="false">https://john-nessime.com/blog/?p=519</guid>

					<description><![CDATA[<p>Tagging a resource and activating a cost allocation tag are two different operations, and AWS never tells you when you have only done the first. A practical guide to the activation gap, what backfill can and cannot repair, the five places coverage leaks, and a retrofit order that actually converges.</p>
<p>The post <a href="https://john-nessime.com/blog/technical-guides/aws-cost-allocation-tags-coverage/">AWS Cost Allocation Tags: Getting Real Coverage on a Messy Bill</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">You tagged everything. The Terraform provider block carries <code>default_tags</code> with <code>Project</code> and <code>Environment</code>, every module inherits them, and you can open the console right now and see those tags sitting on the instances.</p>



<p class="wp-block-paragraph">Then someone in finance asks what the reporting pipeline costs. You open Cost Explorer, group by <code>Project</code>, and over half the bill lands in a bucket called &#8220;No tag key&#8221;.</p>



<p class="wp-block-paragraph">Nothing is broken. The tags are there. They just aren&#8217;t on the bill, because tagging a resource and activating a cost allocation tag are two separate operations, and AWS never warns you when you&#8217;ve done only the first.</p>



<p class="wp-block-paragraph">This post covers getting AWS cost allocation tags to cover a messy account: the activation gap, what backfill will and won&#8217;t repair, where coverage leaks even with decent discipline, and a retrofit order that converges.</p>



<h2 class="wp-block-heading">A resource tag and a cost allocation tag are not the same thing</h2>



<p class="wp-block-paragraph">A resource tag is metadata on the resource. A cost allocation tag is a billing dimension. Getting the second means taking a key that already exists on your resources and explicitly switching it on in the Billing and Cost Management console.</p>



<p class="wp-block-paragraph">Two details make this bite. First, activation lives in the management account. From a member account, the page tells you tags are managed at the payer level and that&#8217;s the end of it. Plenty of teams have a good tagging standard and no access to the one screen that makes it count. Related trap: an account moving between organizations has its activated keys reset to inactive.</p>



<p class="wp-block-paragraph">Second, the lag. AWS documents up to 24 hours for a newly applied key to appear on the activation page, and up to another 24 hours after you activate before it takes effect. The loop between &#8220;we agreed a standard&#8221; and &#8220;I can see it in Cost Explorer&#8221; is two days at best.</p>



<p class="wp-block-paragraph">Past a handful of keys, drive it from the API. Listing the inactive ones is the fastest way to find what you forgot:</p>



<pre class="wp-block-code"><code># Every key AWS knows about that is NOT currently billing-active
aws ce list-cost-allocation-tags --status Inactive

# Switch a set of them on
aws ce update-cost-allocation-tags-status --cli-input-json '{
  "CostAllocationTagsStatus": [
    {"TagKey": "Project",     "Status": "Active"},
    {"TagKey": "Environment", "Status": "Active"},
    {"TagKey": "Owner",       "Status": "Active"}
  ]
}'</code></pre>



<p class="wp-block-paragraph">User-defined keys appear in the report with a <code>user:</code> prefix and AWS-generated ones carry the reserved <code>aws:</code> prefix, so they never collide. There&#8217;s a cap on active keys, raisable through Service Quotas, which you almost certainly don&#8217;t need to raise.</p>



<h2 class="wp-block-heading">What the backfill repairs, and what it quietly doesn&#8217;t</h2>



<p class="wp-block-paragraph">Activation used to be strictly forward-looking, so activating late permanently orphaned everything before it. That&#8217;s no longer true. The management account can ask AWS to reapply current activation status across up to twelve months of historical cost data.</p>



<pre class="wp-block-code"><code># Must be the first day of a month, no earlier than twelve months back
aws ce start-cost-allocation-tag-backfill 
  --backfill-from 2025-01-01T00:00:00Z</code></pre>



<ul class="wp-block-list">
<li>Start date must be the first day of a month, and can&#8217;t reach back more than twelve months.</li>

<li>One request per 24 hours, and you can&#8217;t submit another while one is processing.</li>

<li>It backfills <em>activation status</em>, not tag values.</li>
</ul>



<p class="wp-block-paragraph">That last point catches people. If the resource wasn&#8217;t carrying the tag when the cost was incurred, backfill gives you nothing for those months. It fixes &#8220;I forgot to activate.&#8221; It does not fix &#8220;I forgot to tag.&#8221; A tag applied in June and activated in November backfills to June and no further, whatever start date you hand it.</p>



<p class="wp-block-paragraph">It also cuts both ways. Backfill applies the <em>current</em> status of every key, so a key sitting inactive right now gets stripped out of historical data too. If you&#8217;re pruning an overgrown tag list, backfill before you deactivate the old keys, not after, or you&#8217;ll erase the history you were trying to keep.</p>



<h2 class="wp-block-heading">Where AWS cost allocation tags actually leak coverage</h2>



<p class="wp-block-paragraph">With activation sorted and every Terraform-managed resource tagged, a stubborn slice of the bill still refuses to attribute. It matters which slice, because the fixes have nothing in common.</p>



<h3 class="wp-block-heading">Charges no resource can carry</h3>



<p class="wp-block-paragraph">Support charges, refunds, credits, tax, some Marketplace subscriptions. There&#8217;s no resource to hang a tag on, so tagging discipline never touches them. On accounts with a large support plan or heavy credit activity, that&#8217;s a visible fraction of the invoice.</p>



<p class="wp-block-paragraph">The answer is account tags. AWS added support for using AWS Organizations account tags as a cost allocation dimension, applying to all metered usage inside the tagged account and flowing into Cost Explorer, Budgets, Cost Anomaly Detection and Cost Categories. Because attribution happens at the account level, it picks up exactly what resource tags can&#8217;t reach.</p>



<p class="wp-block-paragraph">This is recent enough that most tagging guides predate it, and it&#8217;s the highest-value item here if your accounts already map roughly onto teams.</p>



<h3 class="wp-block-heading">Resources created by other resources</h3>



<p class="wp-block-paragraph">This is the quiet one, and usually the biggest surprise gap in an otherwise well-run account. Terraform&#8217;s provider-level <code>default_tags</code> applies to resources Terraform creates. It does not reach the EC2 instances an Auto Scaling group launches, because Terraform didn&#8217;t create those, the ASG did. Your ASG is tagged. Your instances aren&#8217;t. Same for volumes attached at launch, ENIs, snapshots and AMIs.</p>



<pre class="wp-block-code"><code>data "aws_default_tags" "current" {}

resource "aws_autoscaling_group" "app" {
  # ... other config ...

  # Push provider default tags down to launched instances
  dynamic "tag" {
    for_each = data.aws_default_tags.current.tags
    content {
      key                 = tag.key
      value               = tag.value
      propagate_at_launch = true
    }
  }
}

resource "aws_launch_template" "app" {
  # ... other config ...

  tag_specifications {
    resource_type = "instance"
    tags          = data.aws_default_tags.current.tags
  }

  tag_specifications {
    resource_type = "volume"
    tags          = data.aws_default_tags.current.tags
  }
}</code></pre>



<p class="wp-block-paragraph">For a standalone instance, <code>volume_tags</code> on <code>aws_instance</code> covers its attached volumes. General rule: whenever one AWS resource provisions another on your behalf, assume tags don&#8217;t propagate until you&#8217;ve checked the created resource in the console, not the plan output.</p>



<h3 class="wp-block-heading">Shared infrastructure that belongs to everyone</h3>



<p class="wp-block-paragraph">NAT gateway data processing, VPC interface endpoints, a Transit Gateway, a shared load balancer, the central logging bucket. You can tag all of these perfectly and the tag says <code>Project=platform</code>, which is accurate and useless for chargeback.</p>



<p class="wp-block-paragraph">Transit Gateway is worth calling out because it supports tagging the gateway resource itself, so data processing and transfer charges pick up the tag rather than only attachment hours. That gap used to swallow a lot of network spend.</p>



<p class="wp-block-paragraph">Beyond that, stop chasing tags and do allocation math instead. Cost Categories support split charge rules that redistribute a shared bucket across your other categories, using proportional splits, fixed percentages or an even division. Proportional is the sane default; fixed percentages are for when finance already agreed a number and the report has to match it.</p>



<h3 class="wp-block-heading">Container spend hiding behind one instance tag</h3>



<p class="wp-block-paragraph">A shared EKS or ECS cluster is one tagged compute footprint running fifteen teams&#8217; workloads. The instance tag gives you the total and nothing about who caused it. Split cost allocation data is the native answer: opt in and the cost and usage export gains rows breaking cluster cost down to individual pods or tasks. It won&#8217;t account for idle headroom you&#8217;re deliberately carrying, but it beats one line item labelled &#8220;the cluster&#8221;.</p>



<h3 class="wp-block-heading">Case drift and near-duplicate keys</h3>



<p class="wp-block-paragraph">Keys and values are case-sensitive. <code>Environment</code>, <code>environment</code> and <code>ENV</code> are three distinct keys producing three columns, each holding a partial and misleading picture. Tag Policies in AWS Organizations exist for this: define the key including its capitalization, define permitted values, and get an org-wide compliance report. Cheapest governance on this list, because it costs nobody a deploy.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">A retrofit order that converges</h2>



<p class="wp-block-paragraph">Retrofits stall because people start at the bottom, sweeping untagged resources by hand while the creation path keeps producing more. This order avoids that.</p>



<ol class="wp-block-list">
<li><strong>Measure first.</strong> Group Cost Explorer by your intended key and record what percentage of spend falls into &#8220;No tag key&#8221;. That&#8217;s your baseline and your only honest progress metric.</li>

<li><strong>Cut the key set down.</strong> Three to five keys: owner, environment, and a project or cost centre. Coverage on five beats partial coverage on thirty.</li>

<li><strong>Activate immediately, even with terrible coverage.</strong> Both clocks start now: the activation delay and the rolling twelve-month backfill window. There&#8217;s no downside to activating a key that&#8217;s barely applied.</li>

<li><strong>Rank the gap by spend, not resource count.</strong> Sort untagged line items by cost descending. It&#8217;s usually a few NAT gateways, a couple of oversized databases and one forgotten bucket. Ten thousand untagged security groups cost nothing.</li>

<li><strong>Fix the creation path before the backlog.</strong> IaC defaults, launch template tag specifications, ASG propagation. Sweeping first means sweeping again next month.</li>

<li><strong>Sweep the stragglers in bulk.</strong> The Resource Groups Tagging API works across services and regions, which beats going service by service.</li>

<li><strong>Backfill, then re-measure.</strong> Compare against step one. If the number barely moved, you fixed the wrong resources.</li>
</ol>



<pre class="wp-block-code"><code># Find what already carries a Project tag, to diff against inventory
aws resourcegroupstaggingapi get-resources --tag-filters Key=Project

# Apply tags to a specific set of ARNs
aws resourcegroupstaggingapi tag-resources 
  --resource-arn-list arn:aws:rds:eu-west-1:111122223333:db:reporting-01 
  --tags Project=reporting,Environment=prod</code></pre>



<p class="wp-block-paragraph">Run it against a couple of ARNs and confirm in the console before looping over a list. Bulk tagging is trivially easy to point at the wrong account.</p>



<h2 class="wp-block-heading">Enforcing tags without becoming the tag police</h2>



<p class="wp-block-paragraph">Four mechanisms, in increasing order of blast radius. Most teams need the first three and should be careful with the fourth.</p>



<ul class="wp-block-list">
<li><strong>IaC defaults.</strong> Provider-level default tags plus required module variables. Cheapest, catches the majority, nobody thinks about it.</li>

<li><strong>CI validation.</strong> A linter rule that fails the pull request on missing required tags. The failure lands on the person who can fix it, when they can fix it.</li>

<li><strong>Detective controls.</strong> The AWS Config managed rule for required tags flags non-compliant resources without blocking anything. Tells the truth, breaks nothing.</li>

<li><strong>Preventive controls.</strong> A service control policy denying creation when a required key is absent, keyed on a null check against the request tag.</li>
</ul>



<pre class="wp-block-code"><code>{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "DenyRDSCreateWithoutProjectTag",
      "Effect": "Deny",
      "Action": "rds:CreateDBInstance",
      "Resource": "*",
      "Condition": {
        "Null": {
          "aws:RequestTag/Project": "true"
        }
      }
    }
  ]
}</code></pre>



<p class="wp-block-paragraph">The honest trade-off: service control policies are where tagging programmes go to die. Not every API supports tag-on-create, and not every legitimate workflow passes tags through the way you expect. Roll a broad deny across the org on day one and you break somebody&#8217;s deployment on day two, the ticket lands on you, and the policy is gone by day three along with everyone&#8217;s appetite for the project.</p>



<p class="wp-block-paragraph">Scope it to one service at a time, starting in sandbox. I&#8217;d rather run Config plus Tag Policies for a quarter and fix the top three offenders in the IaC. The deny is a finishing move, not an opening one.</p>



<h2 class="wp-block-heading">Measuring coverage so it doesn&#8217;t rot</h2>



<p class="wp-block-paragraph">Cost Explorer gives the headline number. For a per-service breakdown, query the cost and usage export in Athena. One caveat: tag column naming differs between export versions, older ones giving a flattened column per activated key and newer formats a map column. Run a <code>DESCRIBE</code> first rather than guessing.</p>



<pre class="wp-block-code"><code>-- Confirm the actual column names first
DESCRIBE your_cur_database.your_cur_table;

-- Then: untagged spend, ranked by service
SELECT   line_item_product_code,
         SUM(line_item_unblended_cost) AS untagged_cost
FROM     your_cur_database.your_cur_table
WHERE    your_project_tag_column = ''
  AND    line_item_unblended_cost &gt; 0
GROUP BY 1
ORDER BY 2 DESC
LIMIT    20;</code></pre>



<p class="wp-block-paragraph">That ranked list is your work queue; everything below the top twenty is noise. The list call also returns a last-used timestamp per key, the cleanest way to find keys nobody has applied to anything in months.</p>



<p class="wp-block-paragraph">If you&#8217;d rather not maintain the dashboard yourself, this is a fair place to buy rather than build. Grafana Cloud sits on Athena comfortably if you already run Grafana, and platforms like CloudZero, Vantage and nOps handle allocation modelling out of the box. Their real value is the shared-cost and commitment maths. Their real cost is another vendor between you and your billing data, and several price against a percentage of the spend they watch, so run that number first.</p>



<h2 class="wp-block-heading">Troubleshooting</h2>



<ul class="wp-block-list">
<li><strong>The key isn&#8217;t in the activation list.</strong> It must exist on at least one resource first, and propagation takes up to a day. Past that, check you&#8217;re in the management account and the key matches exactly, including case.</li>

<li><strong>Activated, but everything still shows untagged.</strong> Give it a full billing cycle. A key switched on mid-month leaves the first half of that month untagged until you backfill.</li>

<li><strong>Tags stopped working after an account move.</strong> Activation doesn&#8217;t survive a move between organizations. Reactivate from the new management account, then backfill.</li>

<li><strong>Cluster costs still land on one team.</strong> Instance tags can&#8217;t split a shared cluster. Opt into split cost allocation data, or treat the cluster as a shared cost in a split charge rule.</li>

<li><strong>You&#8217;ve hit the active key limit.</strong> Almost always too many keys rather than a real need for a quota increase. Use last-used timestamps to find dead ones, and backfill before deactivating.</li>
</ul>



<h2 class="wp-block-heading">Common mistakes</h2>



<ul class="wp-block-list">
<li>Assuming a tagged resource is a tagged cost. It isn&#8217;t until you activate the key.</li>

<li>Waiting for good coverage before activating. Activate first, improve coverage second, backfill third.</li>

<li>Deactivating stale keys and then backfilling, which wipes their history out of the data.</li>

<li>Measuring coverage by resource count instead of by spend.</li>

<li>Rolling out a broad tag-enforcing SCP in production before anyone has read a compliance report.</li>

<li>Trusting <code>default_tags</code> to reach resources Terraform didn&#8217;t directly create.</li>

<li>Putting anything sensitive in a tag value. Tags surface in billing exports that finance and third-party tools read.</li>
</ul>



<h2 class="wp-block-heading">Best practices</h2>



<ul class="wp-block-list">
<li>Keep the mandatory set to three to five keys, with defined allowed values rather than free text.</li>

<li>Pin casing in the standard and enforce it with Tag Policies, because AWS won&#8217;t do it for you.</li>

<li>Tag accounts as well as resources. Account tags cover charges resource tags structurally cannot.</li>

<li>Treat coverage as a tracked percentage of spend with an owner and a monthly trend, not a project that finishes.</li>

<li>Accept an irreducible remainder. Chasing the last few percent by hand costs more than it returns.</li>

<li>Put allocation rules in Cost Categories rather than inventing tag values that encode a split.</li>
</ul>



<h2 class="wp-block-heading">Frequently asked questions</h2>



<h3 class="wp-block-heading">Are AWS cost allocation tags retroactive?</h3>



<p class="wp-block-paragraph">Partly. Activation applies going forward, but the management account can request a backfill reapplying current activation status across up to twelve months. The catch is that the resource must have carried the tag at the time, so backfill recovers late activation, never late tagging.</p>



<h3 class="wp-block-heading">Why don&#8217;t my tags appear in Cost Explorer?</h3>



<p class="wp-block-paragraph">In order of likelihood: the key was never activated in the Billing console; you&#8217;re looking from a member account; the activation delay hasn&#8217;t elapsed; or the key differs in case from the one you activated.</p>



<h3 class="wp-block-heading">How many cost allocation tags can I activate?</h3>



<p class="wp-block-paragraph">There&#8217;s a documented per-account cap on active keys, raisable through Service Quotas, plus a separate per-resource cap on total tags. Check current numbers in the AWS billing quotas docs. In practice the binding limit is human: past roughly a dozen keys, consistency collapses.</p>



<h3 class="wp-block-heading">Should I use account tags or resource tags?</h3>



<p class="wp-block-paragraph">Both, for different jobs. Account tags handle top-level attribution and cover untaggable charges like support, credits and refunds. Resource tags handle granularity inside an account. If your accounts already map cleanly to teams, account tags are the cheaper win and worth doing first.</p>



<h3 class="wp-block-heading">Is this worth doing on a small account?</h3>



<p class="wp-block-paragraph">Scale the effort to the shape of the bill. If you run a handful of services under one account and can name every resource from memory, account tags plus two resource keys is plenty. Worth noticing too that a fixed-price VPS from a provider like Contabo or InterServer has trivially perfect cost allocation, because the bill is one predictable line. Elastic infrastructure buys flexibility and pays for it in attribution work.</p>



<h3 class="wp-block-heading">How do I allocate shared costs like NAT gateways?</h3>



<p class="wp-block-paragraph">Not with tags. Put them in a Cost Categories split charge rule and redistribute proportionally across consuming categories. Tag the shared resource to identify the bucket, then let the rule do the allocation.</p>



<h2 class="wp-block-heading">The one thing worth remembering</h2>



<p class="wp-block-paragraph">AWS cost allocation tags fail silently, and that&#8217;s what makes them frustrating. Nothing errors. No alarm fires. Your resources look correctly tagged in every console you check, the bill quietly disagrees, and you find out when someone asks a question you can&#8217;t answer.</p>



<p class="wp-block-paragraph">So check one thing now: open the cost allocation tags page in your management account and see whether the keys you&#8217;ve been diligently applying are actually switched on. If they aren&#8217;t, activate them today, then backfill. That twelve-month window rolls, and every month you wait is history you can&#8217;t recover.</p>



<p class="wp-block-paragraph">Everything else here is incremental. Coverage is a percentage you improve, not a state you reach.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">Need help getting your AWS bill to explain itself?</h2>



<p class="wp-block-paragraph">Cost attribution is unglamorous and easy to postpone until the quarter someone actually needs the numbers. If your account is already messy, it goes faster with someone who has untangled one before. I help with:</p>



<ul class="wp-block-list">
<li>Auditing tag coverage as a percentage of spend, broken down by service, so you know what you&#8217;re actually fixing</li>

<li>Designing a minimal tag schema and the Tag Policies to keep it consistent across an organization</li>

<li>Closing propagation gaps in Terraform and CloudFormation, around Auto Scaling groups, launch templates and attached storage</li>

<li>Setting up Cost Categories with split charge rules so shared infrastructure stops distorting every team&#8217;s number</li>

<li>Building cost and usage export pipelines in Athena, partitioned so the dashboard isn&#8217;t expensive to load</li>

<li>Planning a staged tag enforcement rollout that doesn&#8217;t break anyone&#8217;s deployment pipeline</li>
</ul>



<p class="wp-block-paragraph">Send me a Cost Explorer screenshot grouped by your main tag key, or the output of a list call against your active tags, and I&#8217;ll tell you where the gap actually is.</p>



<div class="wp-block-buttons is-layout-flex wp-block-buttons-is-layout-flex">
<div class="wp-block-button"><a class="wp-block-button__link wp-element-button" href="https://www.upwork.com/freelancers/~01f15a912ad84a6620" target="_blank" rel="noreferrer noopener">Work with me on Upwork</a></div>
</div>
<p>The post <a href="https://john-nessime.com/blog/technical-guides/aws-cost-allocation-tags-coverage/">AWS Cost Allocation Tags: Getting Real Coverage on a Messy Bill</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>Twenty Client AWS Accounts and Not One Shared Admin Credential</title>
		<link>https://john-nessime.com/blog/devops/client-aws-account-access/</link>
		
		<dc:creator><![CDATA[John Nessime]]></dc:creator>
		<pubDate>Thu, 27 Aug 2026 09:00:00 +0000</pubDate>
				<category><![CDATA[Cloud Computing]]></category>
		<category><![CDATA[Cloud Security]]></category>
		<category><![CDATA[DevOps]]></category>
		<category><![CDATA[AWS Organizations]]></category>
		<category><![CDATA[AWS STS]]></category>
		<category><![CDATA[Client Onboarding]]></category>
		<category><![CDATA[CloudTrail]]></category>
		<category><![CDATA[Confused Deputy]]></category>
		<category><![CDATA[Cross-Account Access]]></category>
		<category><![CDATA[External ID]]></category>
		<category><![CDATA[IAM]]></category>
		<category><![CDATA[IAM Access Analyzer]]></category>
		<category><![CDATA[IAM Identity Center]]></category>
		<category><![CDATA[Least Privilege]]></category>
		<category><![CDATA[Managed Service Providers]]></category>
		<category><![CDATA[MFA]]></category>
		<category><![CDATA[Multi-Account Strategy]]></category>
		<category><![CDATA[Offboarding]]></category>
		<category><![CDATA[Permissions Boundaries]]></category>
		<category><![CDATA[Role Chaining]]></category>
		<category><![CDATA[Session Policies]]></category>
		<category><![CDATA[Source Identity]]></category>
		<guid isPermaLink="false">https://john-nessime.com/blog/?p=546</guid>

					<description><![CDATA[<p>Shared admin credentials work right up until someone resigns or an auditor asks who did what. Here's how to design client AWS account access around cross-account roles, unique external IDs and per-engineer attribution, so twenty accounts need zero shared keys and revocation is one action.</p>
<p>The post <a href="https://john-nessime.com/blog/devops/client-aws-account-access/">Twenty Client AWS Accounts and Not One Shared Admin Credential</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">A client&#8217;s auditor asks one question: list every person at your company who can sign in to our AWS account, and show us how you know. If the honest answer starts with &#8220;whoever has the shared vault,&#8221; that isn&#8217;t an answer. It&#8217;s a hope.</p>



<p class="wp-block-paragraph">That question is what actually bites when you run infrastructure for twenty clients. Not a breach. An audit, or a resignation. Someone gives notice on a Friday and you spend Monday working out which accounts they still hold keys to, because the credential everybody shared was never attached to a person. You can&#8217;t revoke a person from a password. You can only rotate it and hope you got all twenty.</p>



<p class="wp-block-paragraph">This post covers how to design client AWS account access so that humans authenticate once against something you control, every client account holds a role that trusts exactly one thing, and every API call in a client&#8217;s CloudTrail can be traced back to a named engineer. It&#8217;s organised by failure family, because the mechanics are well documented and the failure modes are not.</p>



<h2 class="wp-block-heading">The shape: one identity plane, twenty separate trust decisions</h2>



<p class="wp-block-paragraph">The instinct that causes all the trouble is putting credentials in the middle. Client gives you a login, you store it somewhere shared, engineers take it out when they need it. Everything downstream of that decision is damage control.</p>



<p class="wp-block-paragraph">The shape that works is boring. One AWS account whose only job is identity. Your engineers exist there, once, in IAM Identity Center or federated from whatever directory you already pay for. Each client account holds an IAM role whose trust policy names one principal in that account and requires an external ID. Nobody stores a client credential, because there is no client credential. There&#8217;s a role your engineers can ask STS for a short-lived session against, and a client who can delete that role whenever they like.</p>



<p class="wp-block-paragraph">There is a second design worth knowing about: inviting client accounts into your own AWS Organization. It&#8217;s genuinely better in some ways. You get service control policies, resource control policies, and centralised root access management across the estate, all of which are organisation-scoped features you simply cannot use on an account you don&#8217;t own. It&#8217;s also a much bigger commitment. You take on consolidated billing, the client&#8217;s spend flows through you, and pulling an account back out of an organisation at the end of a contract is a real project rather than a policy change.</p>



<p class="wp-block-paragraph">For most consultancies and managed service providers, the client keeps their own organisation and you get a role. That&#8217;s the model the rest of this assumes.</p>



<h2 class="wp-block-heading">Failure family 1: credentials you can&#8217;t revoke</h2>



<p class="wp-block-paragraph">The shared IAM user with an access key is the default because it works immediately. It keeps working right up until one of three moments: an engineer leaves, a client asks who did something, or a laptop gets stolen.</p>



<p class="wp-block-paragraph">Access keys have no expiry. They&#8217;re bearer tokens with an indefinite lifetime, and the only way to invalidate one is to go into each account and delete it. At twenty accounts that&#8217;s twenty deliberate actions performed under time pressure, and the one you forget is the one that matters. A role session, by contrast, expires on its own. That&#8217;s the entire argument.</p>



<p class="wp-block-paragraph">A shared password manager still earns its place, just not for admin keys. Bitwarden or 1Password is the right home for per-client external IDs, account numbers, role ARNs and the client-side contacts you need when something breaks at two in the morning. Metadata, not credentials.</p>



<h2 class="wp-block-heading">Failure family 2: a trust policy that trusts more than you meant</h2>



<p class="wp-block-paragraph">This is the invisible one. The role gets created, the access works, everyone moves on, and the trust policy quietly grants far more than anyone intended.</p>



<p class="wp-block-paragraph">Two mistakes travel together. The first is naming your account root in the principal. Writing <code>arn:aws:iam::111122223333:root</code> does not mean &#8220;the root user of my account.&#8221; It means any principal in that account that has permission to call AssumeRole. Every engineer, every Lambda function, every CI runner, every future role you haven&#8217;t created yet. It delegates the decision to your own account&#8217;s IAM, which is fine when your IAM is tidy and quietly catastrophic when it isn&#8217;t.</p>



<p class="wp-block-paragraph">The second is skipping the external ID. AWS calls the problem it solves the confused deputy: you are a third party that holds the ability to assume roles in many customer accounts, and without a shared secret in the condition, one customer who learns another customer&#8217;s role ARN could persuade your systems to act against it. The external ID is the value that makes each trust relationship specific rather than generic.</p>



<p class="wp-block-paragraph">Generate it yourself, one per client, as a UUID or similar unguessable value. The client must not be the one choosing it, because if they can choose it they can choose someone else&#8217;s, and the control does nothing.</p>



<pre class="wp-block-code"><code>{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "AWS": "arn:aws:iam::111122223333:role/MspOperationsAccess"
      },
      "Action": "sts:AssumeRole",
      "Condition": {
        "StringEquals": {
          "sts:ExternalId": "6f1c2e94-3a77-4c2b-9f0d-8e5a1b7c4d02"
        }
      }
    }
  ]
}</code></pre>



<p class="wp-block-paragraph">The principal names one role, not the account. If that role is compromised you have one thing to fix. If the account is named, you have an inventory problem you didn&#8217;t know you had.</p>



<h2 class="wp-block-heading">Failure family 3: CloudTrail says the role, not the human</h2>



<p class="wp-block-paragraph">You&#8217;ve done everything above and a client emails asking who deleted the staging load balancer. You open their CloudTrail and every event attributes to the same assumed role. The role is shared by your whole team by design, so the log tells you your company did it. That&#8217;s not what they asked.</p>



<p class="wp-block-paragraph">There are two knobs and they are not equivalent. The role session name is chosen freely by whoever calls AssumeRole, appears in the assumed-role ARN, and is trivially set to anything. It&#8217;s useful, but it&#8217;s a label, not evidence. Source identity is the one that carries weight: once set on a session it cannot be changed, it persists across chained role sessions, and it lands in CloudTrail in the session context of every subsequent call.</p>



<p class="wp-block-paragraph">The catch is that the role&#8217;s trust policy has to permit <code>sts:SetSourceIdentity</code> alongside <code>sts:AssumeRole</code>, and you can require it with a condition so a session without one is refused outright.</p>



<pre class="wp-block-code"><code>"Action": [
  "sts:AssumeRole",
  "sts:SetSourceIdentity"
],
"Condition": {
  "StringEquals": {
    "sts:ExternalId": "6f1c2e94-3a77-4c2b-9f0d-8e5a1b7c4d02"
  },
  "StringLike": {
    "sts:SourceIdentity": "*@yourcompany.com"
  }
}</code></pre>



<p class="wp-block-paragraph">If your engineers arrive through SAML federation, the identity provider can populate source identity from a directory attribute such as email, which means nobody has to remember to pass it. For direct CLI use it&#8217;s an explicit flag:</p>



<pre class="wp-block-code"><code>aws sts assume-role 
  --role-arn arn:aws:iam::444455556666:role/MspEngineerAccess 
  --role-session-name acme-investigation 
  --external-id 6f1c2e94-3a77-4c2b-9f0d-8e5a1b7c4d02 
  --source-identity jane.doe@yourcompany.com</code></pre>



<p class="wp-block-paragraph">Getting this right is what turns &#8220;we take security seriously&#8221; into a sentence you can back with a log query. It is also, in my experience of what clients actually ask for, the single most persuasive thing on the list.</p>



<h2 class="wp-block-heading">Failure family 4: one identity, twenty blast radii</h2>



<p class="wp-block-paragraph">A hub account is a concentration of risk. That&#8217;s the trade you&#8217;re making, and it&#8217;s worth making, but only if the hub is held to a higher standard than the accounts it reaches into.</p>



<p class="wp-block-paragraph">Practically: not every engineer gets permission to assume every client&#8217;s role. Group permissions in the hub so the people on the Acme engagement reach Acme and nothing else. Use per-client role names rather than one shared name across all twenty, so a policy in the hub can&#8217;t wildcard its way into a client you stopped working with a year ago.</p>



<p class="wp-block-paragraph">Tier the roles by what they&#8217;re for, not by who&#8217;s asking. A read-only role for investigation covers most of the work most days. A change role carries the write permissions the engagement actually needs. Break-glass sits separate, with a short maximum session duration and an alarm on every use.</p>



<p class="wp-block-paragraph">Two extras are worth knowing about even if you don&#8217;t reach for them immediately. Session policies let the caller narrow a session further at assume time, which is useful for automation that only needs one bucket. Permissions boundaries cap what any role or user created inside the client account can do, which matters when part of your job is creating IAM there.</p>



<p class="wp-block-paragraph">If your team already routes through a business VPN or zero trust gateway with stable egress addresses, a source IP condition on the trust policy is a cheap extra layer. NordLayer, Tailscale and Cloudflare&#8217;s zero trust products all give you predictable egress. Be honest about the cost, though: the moment something runs from CI, a client&#8217;s network or a laptop on hotel wifi, an IP condition becomes a support ticket. I&#8217;d add it once everything else works, not before.</p>



<h2 class="wp-block-heading">Failure family 5: standing administrator access nobody remembers granting</h2>



<p class="wp-block-paragraph">Somewhere in the twenty accounts there is a role with AdministratorAccess attached, created during a migration, described in the ticket as temporary. It&#8217;s been eighteen months.</p>



<p class="wp-block-paragraph">Here&#8217;s the honest part: AWS does not give you a native just-in-time approval workflow for cross-account role assumption. There&#8217;s no built-in &#8220;request four hours of write access, get approved in Slack, access expires automatically&#8221; for this pattern. You either build that on top of STS and your ticketing system, or you accept standing access and compensate with tight session durations, aggressive monitoring and a genuine periodic review. Both are defensible. Pretending you have JIT when you have a wiki page is not.</p>



<p class="wp-block-paragraph">The cheap version that actually helps: set the maximum session duration on client roles to something short, alert on assumption of the break-glass role, and run a quarterly pass over service last accessed data to strip permissions nobody has used. IAM can generate that report per role.</p>



<pre class="wp-block-code"><code># Kick off the report, then fetch it with the returned job ID
aws iam generate-service-last-accessed-details 
  --arn arn:aws:iam::444455556666:role/MspEngineerAccess

aws iam get-service-last-accessed-details --job-id JOB_ID_FROM_ABOVE</code></pre>



<p class="wp-block-paragraph">Run that in each client account and the conversation about narrowing permissions stops being theoretical. You&#8217;re not guessing what the engagement needs; you&#8217;re reading it.</p>



<h2 class="wp-block-heading">Failure family 6: offboarding that only half happens</h2>



<p class="wp-block-paragraph">The whole point of the hub is that offboarding is one action. Disable the person in your directory and every path into every client account closes at once, because there was never a credential in a client account with their name on it.</p>



<p class="wp-block-paragraph">That&#8217;s true only if nothing crept in sideways. Things that creep in sideways:</p>



<ul class="wp-block-list">
<li>IAM users created directly in a client account during an incident, because it was faster</li>

<li>Access keys minted under a role by automation and never inventoried</li>

<li>A client&#8217;s own console user shared with your team &#8220;just for the migration&#8221;</li>

<li>Third party tools connected with their own cross-account roles that outlived the trial</li>

<li>A departing engineer&#8217;s laptop with cached SSO tokens and stale profiles still on disk</li>
</ul>



<p class="wp-block-paragraph">IAM Access Analyzer is the tool that finds most of the first four. Its external access findings show every principal outside the account that can reach into it, which is exactly the list a client wants to see, and it&#8217;s also the list that reveals the monitoring vendor nobody remembers signing up for. For the laptop, whatever your hardware disposal process is, make sure it involves an actual wipe rather than a factory reset; tools like O&amp;O SafeErase exist for that reason and are cheap next to the alternative.</p>



<h2 class="wp-block-heading">Rolling client AWS account access out across twenty accounts</h2>



<p class="wp-block-paragraph">Doing this by hand twenty times guarantees twenty subtly different configurations. Make it a template and a checklist.</p>



<ol class="wp-block-list">
<li>Write one CloudFormation template that creates the role, its trust policy and its permissions, parameterised by external ID and your operations account ID.</li>

<li>Generate a unique external ID per client and store it with the client&#8217;s account metadata, not in the template.</li>

<li>Send the client a launch stack link rather than asking for credentials. They deploy it in their own account, in their own console, and can read exactly what they&#8217;re granting.</li>

<li>Record the account ID, role ARN, external ID location, engagement scope and review date in a single inventory file in version control.</li>

<li>Generate everyone&#8217;s local AWS profiles from that inventory so nobody hand-edits a config file.</li>

<li>Verify each one with a call that proves identity and nothing else before you touch anything real.</li>
</ol>



<p class="wp-block-paragraph">The generated profile chains from your federated session into the client role. The AWS CLI reads these from the shared config file and handles the assume-role call for you:</p>



<pre class="wp-block-code"><code># ~/.aws/config
[profile msp-hub]
sso_session = company
sso_account_id = 111122223333
sso_role_name = MspOperationsAccess
region = eu-west-1

[profile acme-readonly]
role_arn = arn:aws:iam::444455556666:role/MspEngineerReadOnly
source_profile = msp-hub
external_id = 6f1c2e94-3a77-4c2b-9f0d-8e5a1b7c4d02
role_session_name = jane-doe
duration_seconds = 3600
region = eu-west-1</code></pre>



<p class="wp-block-paragraph">Then the two-second sanity check that has saved me from more mistakes than any other command:</p>



<pre class="wp-block-code"><code>aws sts get-caller-identity --profile acme-readonly</code></pre>



<p class="wp-block-paragraph">It returns the account ID and the assumed-role ARN. Read the account number before you run anything destructive. Twenty profiles in one config file is exactly the situation where muscle memory picks the wrong one.</p>



<p class="wp-block-paragraph">For scheduled work that runs without a human, the same trust relationship serves a Lambda function or an EC2 instance role in your hub. A low-cost VPS from Contabo or InterServer will also do the job, but note the trade: an instance profile inside AWS gets rotated credentials automatically, while a box outside AWS needs a long-lived credential to bootstrap from, which reintroduces exactly what this design removes. Inside AWS is the cleaner answer.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">Troubleshooting the failures you will actually hit</h2>



<h3 class="wp-block-heading">AccessDenied on AssumeRole</h3>



<p class="wp-block-paragraph">Three separate causes produce nearly the same message, and they need different fixes. Either the client&#8217;s trust policy doesn&#8217;t name your principal, or the external ID you sent doesn&#8217;t match what the condition expects, or your own identity in the hub lacks permission to call AssumeRole on that role ARN. Check them in that order, because the first two require the client and the third doesn&#8217;t.</p>



<h3 class="wp-block-heading">Switch Role in the console silently fails</h3>



<p class="wp-block-paragraph">This one wastes an afternoon if you don&#8217;t know it. The console&#8217;s switch role feature does not support roles that require an external ID. It&#8217;s a documented limitation, not a bug in your policy. Engineers who need console access to those accounts should reach them through federation rather than the switch role box.</p>



<h3 class="wp-block-heading">Sessions expiring after an hour when you asked for longer</h3>



<p class="wp-block-paragraph">When one role session assumes another role, that&#8217;s role chaining, and a chained session is capped at one hour regardless of the role&#8217;s maximum session duration setting. Since your engineers assume the client role from an already-assumed session in the hub, you&#8217;re chaining by definition. Plan around the hour rather than fighting it.</p>



<h3 class="wp-block-heading">An authorization message you can&#8217;t read</h3>



<p class="wp-block-paragraph">Some denials come back with a long encoded blob attached. STS will decode it if your identity has permission to, and the decoded version usually names the exact statement that denied you.</p>



<pre class="wp-block-code"><code>aws sts decode-authorization-message --encoded-message &lt;encoded-blob&gt;</code></pre>



<h2 class="wp-block-heading">Common mistakes</h2>



<ul class="wp-block-list">
<li>Naming the account root as the trust principal instead of the specific operations role</li>

<li>Letting the client pick the external ID, or reusing one value across several clients</li>

<li>Treating the role session name as an audit trail when it&#8217;s a caller-supplied string</li>

<li>Asking a client for console credentials because a template felt like too much effort</li>

<li>Granting AdministratorAccess for a migration and never revisiting it</li>

<li>Building the hub properly and then leaving one legacy IAM user in one account as a fallback</li>

<li>Assuming that removing someone from your directory also removed the keys they created by hand</li>
</ul>



<h2 class="wp-block-heading">Best practices worth the effort</h2>



<ul class="wp-block-list">
<li>One identity plane, federated, with MFA enforced at the point the human authenticates</li>

<li>A unique unguessable external ID per client, generated by you and stored with client metadata</li>

<li>Source identity required in the trust policy so CloudTrail names a person, not a role</li>

<li>Roles tiered by purpose: read-only for investigation, change for the engagement, break-glass alarmed</li>

<li>Client-owned policies, deployed by the client from your template, revocable by the client in one action</li>

<li>A version-controlled inventory of every account, role, scope and review date</li>

<li>Access Analyzer external access findings reviewed on a schedule, in every client account</li>
</ul>



<h2 class="wp-block-heading">FAQ</h2>



<h3 class="wp-block-heading">Can&#8217;t I just ask each client to create an IAM user for me?</h3>



<p class="wp-block-paragraph">You can, and it&#8217;ll work on day one. The problem is day four hundred. That user&#8217;s access key never expires, it isn&#8217;t attached to any individual on your team, and revoking it means someone in the client&#8217;s organisation remembering to act. A role costs the client the same five minutes to create and hands them a control they can exercise unilaterally.</p>



<h3 class="wp-block-heading">Do I need a different external ID for every client?</h3>



<p class="wp-block-paragraph">Yes. A shared external ID across your client base defeats the purpose, because the value&#8217;s entire job is to be specific to one trust relationship. Generate a UUID per client, treat it as a secret, and never let the client choose it.</p>



<h3 class="wp-block-heading">Should I bring client accounts into my own AWS Organization?</h3>



<p class="wp-block-paragraph">Only if you&#8217;re also taking on their billing and the commercial relationship supports it. Inside your organisation you get service control policies, resource control policies and centralised root access management, which are real advantages. Outside it, those stay the client&#8217;s tools and your job is to recommend them well. Most consulting engagements sit outside.</p>



<h3 class="wp-block-heading">How do I prove a departed engineer no longer has access?</h3>



<p class="wp-block-paragraph">Show the directory record disabling them, then show that no IAM user or access key in the client account carries their name, then show Access Analyzer external access findings listing only your operations role as an external principal. Three artefacts, all exportable. That&#8217;s the evidence pack.</p>



<h3 class="wp-block-heading">Does MFA carry across the cross-account role assumption?</h3>



<p class="wp-block-paragraph">Don&#8217;t assume it does. The reliable place to enforce MFA is where the human authenticates in your identity plane, and the reliable control at the client boundary is the combination of a narrowly scoped principal and the external ID condition. If you want an MFA condition in the client&#8217;s trust policy as well, test it end to end against your actual federation path before you rely on it, because the request context differs between direct assumption and chained sessions.</p>



<h3 class="wp-block-heading">What&#8217;s the minimum viable version if I only have three clients?</h3>



<p class="wp-block-paragraph">A hub account with federated engineers, one role per client scoped to a named principal with a unique external ID, and source identity required. Skip the tiering, skip the IP conditions, skip the JIT ambitions. Those three things give you revocation, attribution and a clean answer for the auditor, which is most of the value.</p>



<h2 class="wp-block-heading">The one thing to take away</h2>



<p class="wp-block-paragraph">Client AWS account access is not really a permissions problem. It&#8217;s a revocation and attribution problem wearing a permissions costume. Any design where a credential exists that isn&#8217;t tied to a specific human will fail you at exactly the wrong moment, and no amount of policy tuning fixes that, because the gap isn&#8217;t in the policy.</p>



<p class="wp-block-paragraph">Put the humans in one place you control. Put a narrowly scoped, externally identified role in each client account. Require source identity so the log names a person. Everything else on this page is refinement; those three are the structure. And the pleasant side effect is that &#8220;here&#8217;s how you revoke us, unilaterally, in one action&#8221; is a genuinely good thing to be able to say in a sales conversation.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">Need a second pair of eyes on your client access model?</h2>



<p class="wp-block-paragraph">This is the kind of work I do for consultancies and managed service providers who&#8217;ve grown past the point where a shared vault is survivable. Specifically:</p>



<ul class="wp-block-list">
<li>Reviewing existing cross-account trust policies for over-broad principals and missing external IDs</li>

<li>Designing a hub account and federation model that scales past a handful of clients without per-client special cases</li>

<li>Building the CloudFormation or Terraform template clients deploy themselves, so onboarding stops involving credentials</li>

<li>Turning on source identity end to end, including the identity provider side, so CloudTrail attributes to named engineers</li>

<li>Producing the offboarding evidence pack clients and auditors ask for, and automating it so it isn&#8217;t a scramble</li>

<li>Narrowing long-standing administrator roles down using service last accessed data, without breaking the engagement</li>
</ul>



<p class="wp-block-paragraph">If you want a concrete starting point, send me one trust policy and the AccessDenied message it produces. That&#8217;s usually enough to tell where the model is leaking.</p>



<div class="wp-block-buttons is-layout-flex wp-block-buttons-is-layout-flex">
<div class="wp-block-button"><a class="wp-block-button__link wp-element-button" href="https://www.upwork.com/freelancers/~01f15a912ad84a6620" target="_blank" rel="noreferrer noopener">Work with me on Upwork</a></div>
</div>
<p>The post <a href="https://john-nessime.com/blog/devops/client-aws-account-access/">Twenty Client AWS Accounts and Not One Shared Admin Credential</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>AWS Cross-Account Data Sharing Done Safely: Share Access, Not Copies</title>
		<link>https://john-nessime.com/blog/cloud-computing/aws-cross-account-data-sharing/</link>
		
		<dc:creator><![CDATA[John Nessime]]></dc:creator>
		<pubDate>Sat, 22 Aug 2026 09:00:00 +0000</pubDate>
				<category><![CDATA[Cloud Computing]]></category>
		<category><![CDATA[Cloud Security]]></category>
		<category><![CDATA[Data Engineering]]></category>
		<category><![CDATA[Amazon S3]]></category>
		<category><![CDATA[AWS]]></category>
		<category><![CDATA[AWS KMS]]></category>
		<category><![CDATA[AWS Organizations]]></category>
		<category><![CDATA[AWS RAM]]></category>
		<category><![CDATA[Bucket Policy]]></category>
		<category><![CDATA[Cross-Account Access]]></category>
		<category><![CDATA[Data Governance]]></category>
		<category><![CDATA[Data Sharing]]></category>
		<category><![CDATA[External ID]]></category>
		<category><![CDATA[IAM]]></category>
		<category><![CDATA[IAM Access Analyzer]]></category>
		<category><![CDATA[Lake Formation]]></category>
		<category><![CDATA[Least Privilege]]></category>
		<category><![CDATA[Multi-Account Strategy]]></category>
		<category><![CDATA[Resource Control Policies]]></category>
		<guid isPermaLink="false">https://john-nessime.com/blog/?p=445</guid>

					<description><![CDATA[<p>Adding another account to a bucket policy takes one line and delegates far more than most people realize. A practical look at AWS cross-account data sharing: the delegation model, the KMS gaps, object ownership, references versus copies, and the guardrails that catch the mistake you will eventually make.</p>
<p>The post <a href="https://john-nessime.com/blog/cloud-computing/aws-cross-account-data-sharing/">AWS Cross-Account Data Sharing Done Safely: Share Access, Not Copies</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">The request usually arrives as one sentence in Slack. &#8220;Analytics moved to their own account, can you just add them to the bucket policy?&#8221;</p>



<p class="wp-block-paragraph">It is a one-line change. It works on the first try. And it is the grant most likely to still be sitting there long after that analytics project shipped, covering a wider set of identities than the person who wrote it ever had in mind.</p>



<p class="wp-block-paragraph">That asymmetry is the whole problem. A denied cross-account request is a nuisance: someone pings you, you read the policy, you fix it before lunch. A cross-account grant that succeeds, keeps succeeding, and quietly reaches further than intended produces no error, no alert, and no ticket. It surfaces months later during an audit, when someone asks who has been reading that prefix and the honest answer is &#8220;anyone the other account decided to let in.&#8221;</p>



<p class="wp-block-paragraph">This post covers AWS cross-account data sharing as a set of failure families rather than a service tour. How the delegation model actually behaves, where encryption keys break or widen access without warning, who ends up owning the bytes after a handoff, why a copy is worse than a reference, and which guardrails catch the mistake you are eventually going to make. Real policy snippets and commands throughout, plus the denials that are hardest to read.</p>



<h2 class="wp-block-heading">AWS cross-account data sharing is a delegation, not a permission</h2>



<p class="wp-block-paragraph">Start with the mechanic that everything else hangs off. For a request that crosses an account boundary, two separate accounts have to say yes. The resource-based policy in the account that owns the data has to allow the caller, and an identity-based policy in the calling account has to allow the action. Neither side alone is enough.</p>



<p class="wp-block-paragraph">That is why &#8220;I added them to the bucket policy and it still fails&#8221; is such a routine ticket. The producer did their half. Nobody attached the matching IAM policy on the consumer side.</p>



<p class="wp-block-paragraph">Now the part that bites. Look at how most cross-account bucket policies get written:</p>



<pre class="wp-block-code"><code>{
  "Sid": "AllowAnalyticsAccount",
  "Effect": "Allow",
  "Principal": { "AWS": "arn:aws:iam::111122223333:root" },
  "Action": ["s3:GetObject", "s3:ListBucket"],
  "Resource": [
    "arn:aws:s3:::shared-events-bucket",
    "arn:aws:s3:::shared-events-bucket/exports/*"
  ]
}</code></pre>



<p class="wp-block-paragraph">Despite how it reads, that <code>:root</code> suffix does not mean the root user. It means the account. You have not granted access to a principal at all. You have delegated the guest list to the administrators of account 111122223333, and they can hand that access to any principal they like, including a role somebody creates next quarter for a purpose you will never hear about.</p>



<p class="wp-block-paragraph">Sometimes that delegation is exactly what you want, because the consumer team manages its own roles and you do not want to be in the loop for every change. Usually you want the narrower version:</p>



<pre class="wp-block-code"><code>{
  "Sid": "AllowAnalyticsRoleOnly",
  "Effect": "Allow",
  "Principal": { "AWS": "arn:aws:iam::111122223333:role/analytics-reader" },
  "Action": ["s3:GetObject", "s3:ListBucket"],
  "Resource": [
    "arn:aws:s3:::shared-events-bucket",
    "arn:aws:s3:::shared-events-bucket/exports/*"
  ],
  "Condition": {
    "StringEquals": { "aws:PrincipalOrgID": "o-abc123def4" }
  }
}</code></pre>



<p class="wp-block-paragraph">Both halves earn their place. The named ARN stops the wrong role in the right account. The organization condition stops the right role name in the wrong account, which matters if the statement is ever copied, templated, or widened by someone in a hurry. One protects against internal drift, the other against the policy escaping your organization entirely.</p>



<p class="wp-block-paragraph">Be clear-eyed about the trade-off, though. Naming ARNs is precise and brittle: every new consumer role is a policy change, a pull request, and a review. Organization-wide conditions scale beautifully and narrow nothing inside a member account. If your organization contains twelve accounts and forty teams, <code>aws:PrincipalOrgID</code> on its own is a fairly wide door.</p>



<h3 class="wp-block-heading">The grant that stops working after a rebuild</h3>



<p class="wp-block-paragraph">When you name a role ARN as a principal, IAM stores the role&#8217;s internal unique ID behind the scenes. Delete that role and the policy will start displaying an opaque <code>AROA...</code> string where the ARN used to be, because the ARN no longer resolves to anything. Recreate a role with the identical name and the access does not come back, because the new role has a new unique ID.</p>



<p class="wp-block-paragraph">A <code>terraform destroy</code> followed by <code>terraform apply</code> on the consumer side reproduces this perfectly. If you are staring at a bucket policy that contains a principal starting with AROA, that is your answer. Rewrite the statement with the current ARN.</p>



<h2 class="wp-block-heading">The encryption boundary nobody tests until it is live</h2>



<p class="wp-block-paragraph">Second failure family: the key. Encrypting shared data is the easy decision. What people miss is that a KMS key is a second, fully independent authorization surface, with its own resource policy, evaluated separately from the bucket.</p>



<ul class="wp-block-list">
<li>Downloading an object encrypted with SSE-KMS requires <code>kms:Decrypt</code> on the key.</li>

<li>Uploading one requires <code>kms:GenerateDataKey</code>.</li>

<li>A multipart upload requires both.</li>
</ul>



<p class="wp-block-paragraph">The AWS managed key for S3 cannot carry you here, because you cannot edit its key policy. Sharing KMS-encrypted objects across accounts means a customer managed key, with a statement naming the consumer:</p>



<pre class="wp-block-code"><code>{
  "Sid": "AllowAnalyticsRoleToDecrypt",
  "Effect": "Allow",
  "Principal": { "AWS": "arn:aws:iam::111122223333:role/analytics-reader" },
  "Action": ["kms:Decrypt", "kms:DescribeKey"],
  "Resource": "*",
  "Condition": {
    "StringEquals": { "kms:ViaService": "s3.us-east-1.amazonaws.com" }
  }
}</code></pre>



<p class="wp-block-paragraph">The <code>kms:ViaService</code> condition is cheap insurance. It restricts use of the key to requests that arrive through S3 in that region, so a leaked set of consumer credentials cannot call KMS directly and start decrypting ciphertext it obtained some other way. And the two-key rule applies to the key exactly as it does to the bucket: the consumer&#8217;s own IAM policy has to allow the KMS actions too.</p>



<p class="wp-block-paragraph">Now the sharp edge, which is worth reading twice. If a KMS key is specified by <em>alias</em> rather than by fully qualified ARN, the alias is resolved in the requester&#8217;s account. In a cross-account write that means data landing in your bucket can end up encrypted under a key belonging to the writer. You own the object and cannot read it, and no error is raised at write time. Use full key ARNs in anything that crosses an account boundary.</p>



<h2 class="wp-block-heading">Who owns the bytes after the handoff</h2>



<p class="wp-block-paragraph">Third family: ownership, and it depends entirely on which direction the data moves.</p>



<p class="wp-block-paragraph">New buckets are created with Object Ownership set to Bucket owner enforced. ACLs are off, and the bucket owner owns every object written into the bucket regardless of who wrote it. That default removed the single most confusing trap in S3 sharing.</p>



<p class="wp-block-paragraph">Buckets created before that default are the ones to check. If a bucket still sits on the Object writer setting, an object uploaded by another account is owned by that account, and you, holding the bucket, may not be able to read it. Nothing fails at write time. It fails later, when a job in your own account tries to read your own bucket and gets a 403.</p>



<p class="wp-block-paragraph">Check and fix it with the ownership controls API:</p>



<pre class="wp-block-code"><code>aws s3api get-bucket-ownership-controls --bucket shared-events-bucket

aws s3api put-bucket-ownership-controls 
  --bucket shared-events-bucket 
  --ownership-controls '{"Rules":[{"ObjectOwnership":"BucketOwnerEnforced"}]}'</code></pre>



<p class="wp-block-paragraph">Order matters when you make that change on a live bucket. Once ACLs are disabled, any PUT that still carries an ACL is rejected with <code>AccessControlListNotSupported</code>. Requests that send no ACL, or send <code>bucket-owner-full-control</code>, keep working. So the sequence is:</p>



<ol class="wp-block-list">
<li>Find every writer that sets an ACL, including old scripts using <code>--acl</code> and any SDK call passing an ACL argument.</li>

<li>Update those clients so they stop sending one.</li>

<li>Confirm from CloudTrail that ACL-bearing PUTs have stopped.</li>

<li>Flip the bucket to Bucket owner enforced.</li>
</ol>



<p class="wp-block-paragraph">Given a free choice of design, I would rather the consumer pull from a bucket I own than push into a bucket I do not. Pull keeps ownership, lifecycle rules, encryption configuration, and access logging in one account, under one team&#8217;s control. Push scatters them.</p>



<h2 class="wp-block-heading">Copies you cannot recall</h2>



<p class="wp-block-paragraph">Fourth family, and the one with the longest tail. There is a large difference between granting a reference and shipping a copy. A reference can be revoked on a Tuesday afternoon. A copy is simply gone.</p>



<p class="wp-block-paragraph">Three reference-shaped patterns are worth knowing before you reach for a bulk export.</p>



<h3 class="wp-block-heading">S3 Access Points for object-level sharing</h3>



<p class="wp-block-paragraph">Instead of a bucket policy that grows a new statement every time a team asks for something, you write the bucket policy once to delegate to access points, then give each consumer their own access point with its own focused policy. The delegation is expressed with the <code>s3:DataAccessPointAccount</code> condition. Access points can also be owned by the consumer&#8217;s account, so they manage their own detail without touching your bucket policy.</p>



<p class="wp-block-paragraph">The gotcha: when a request goes through an access point, both the access point policy and the underlying bucket policy have to allow it. A 403 here is easy to misdiagnose because the bucket policy looks correct and is correct. The access point is the layer that said no.</p>



<h3 class="wp-block-heading">Lake Formation for table-level sharing</h3>



<p class="wp-block-paragraph">When the thing being shared is a table rather than a prefix, Lake Formation is the better fit, because it lets you grant on databases, tables, columns, and row filters instead of on object paths. Cross-account grants ride on AWS Resource Access Manager, so the recipient gets an invitation that a data lake administrator has to accept.</p>



<pre class="wp-block-code"><code>aws ram get-resource-share-invitations

aws ram accept-resource-share-invitation 
  --resource-share-invitation-arn arn:aws:ram:us-east-1:111122223333:resource-share-invitation/EXAMPLE</code></pre>



<p class="wp-block-paragraph">After the invitation is accepted, the shared database and tables are visible but still not queryable from Athena or Redshift Spectrum until someone creates a <em>resource link</em>, which behaves roughly like a symlink in the recipient&#8217;s Data Catalog. Missing resource links account for most of the &#8220;the share went through but I cannot see the table&#8221; messages.</p>



<p class="wp-block-paragraph">One setting to check on the producer side is the cross-account data sharing version. Newer versions collapse many grants into far fewer RAM resource shares, which matters once you are sharing at any scale, and they allow granting directly to IAM principals in another account rather than only at account level. Read the current version with:</p>



<pre class="wp-block-code"><code>aws lakeformation get-data-lake-settings</code></pre>



<h3 class="wp-block-heading">When a copy really is the answer</h3>



<p class="wp-block-paragraph">Sometimes the partner genuinely needs the data on their own infrastructure: a warehouse you have no reach into, a VPS at a provider like Contabo or InterServer, or in the worst case an analyst&#8217;s laptop. That is a legitimate outcome, but be honest with yourself and with them that your controls stop at the handoff.</p>



<p class="wp-block-paragraph">Practical hygiene for that case: scope the extract to the columns actually needed rather than the whole table, put an expiry date in the agreement rather than in your head, and write down what happens to the copy when the engagement ends. If the copy lives on machines you or the client control, verified erasure with something like O&amp;O SafeErase reads a great deal better in an audit than &#8220;we deleted the folder.&#8221; And if the recipient has no AWS account at all, a time-limited presigned URL is usually a better answer than minting an IAM user with long-lived keys.</p>



<h2 class="wp-block-heading">The guardrail that catches the mistake you will make</h2>



<p class="wp-block-paragraph">Everything above describes what a careful engineer does per grant. Guardrails are what happens when somebody is not careful, which over a long enough window includes all of us.</p>



<p class="wp-block-paragraph">Resource control policies are the strongest lever if you run AWS Organizations. An RCP sets a ceiling on what any resource-based policy inside the organization can grant. The canonical statement is &#8220;no principal outside my organization may access S3 buckets in my accounts, regardless of what an individual bucket policy says.&#8221; That inverts the risk model: a careless bucket policy is no longer an exposure, it is just an ineffective statement.</p>



<p class="wp-block-paragraph">Two limits are worth stating plainly. RCPs cover a specific set of services rather than everything in AWS, so check the current supported list rather than assuming coverage. And they constrain your resources, not a partner&#8217;s behavior once data has legitimately left. An RCP is a wall around your side of the fence.</p>



<p class="wp-block-paragraph">Service control policies do the mirror-image job on the consumer side, limiting which external accounts your own principals are allowed to assume roles into. Account-level S3 Block Public Access remains a sensible floor underneath both.</p>



<p class="wp-block-paragraph">For sharing with a third party rather than an internal account, the external ID is not optional in my book:</p>



<pre class="wp-block-code"><code>{
  "Effect": "Allow",
  "Principal": { "AWS": "arn:aws:iam::444455556666:role/vendor-connector" },
  "Action": "sts:AssumeRole",
  "Condition": {
    "StringEquals": { "sts:ExternalId": "a-value-the-vendor-generated-for-you" }
  }
}</code></pre>



<p class="wp-block-paragraph">The external ID exists because of the confused deputy problem. A vendor holds credentials that can assume roles across many customers, and without a per-customer secret in the condition, customer A can trick the vendor into pointing that access at customer B. If a vendor asks you to create a role for them with no external ID and no conditions at all, that is worth a conversation before it is worth a role.</p>



<h2 class="wp-block-heading">Verifying that the grant is what you think it is</h2>



<p class="wp-block-paragraph">Reading policies is not the same as knowing what they permit. IAM Access Analyzer answers the question &#8220;what is currently reachable from outside my zone of trust&#8221; using automated reasoning rather than pattern matching, which is why it catches combinations a human reviewer skims past.</p>



<pre class="wp-block-code"><code>aws accessanalyzer create-analyzer 
  --analyzer-name external-access 
  --type ACCOUNT

aws accessanalyzer list-findings 
  --analyzer-arn arn:aws:access-analyzer:us-east-1:999988887777:analyzer/external-access</code></pre>



<p class="wp-block-paragraph">Scope the zone of trust to your organization where you have one, then archive the findings that represent intentional shares, so that a new entry in the list actually means something.</p>



<p class="wp-block-paragraph">The same service exposes custom policy checks you can run in a pipeline before a policy change merges, including checks for public access, for specific actions being granted, and for whether an updated policy grants more than the one it replaces. One thing to understand about them: they evaluate the document you hand them and nothing else. They do not read your account state, so a public-access check can fail on a policy in an account that has Block Public Access enabled. For a merge gate that is correct behavior, because the policy should not be relying on a distant safety net.</p>



<p class="wp-block-paragraph">Finally, CloudTrail tells you who actually used a grant, which is the input to the only cleanup that ever really works: delete the statements nobody has exercised. Routing that history into CloudWatch, or into a Grafana Cloud dashboard alongside the rest of your platform telemetry, turns &#8220;is this still needed&#8221; from an argument into a query.</p>



<h2 class="wp-block-heading">Troubleshooting the denials that are hard to read</h2>



<ul class="wp-block-list">
<li><strong>You can list the bucket but every GET returns 403.</strong> That pattern points at KMS, not S3. Listing does not touch the key; reading does. Check <code>kms:Decrypt</code> on both the key policy and the consumer&#8217;s identity policy.</li>

<li><strong>403 through an access point while the bucket policy is clearly correct.</strong> The access point has its own policy and it also has to allow the calling principal. Read that one next.</li>

<li><strong>AssumeRole is denied although the trust policy names the caller.</strong> Cross-account needs both halves: the caller&#8217;s identity policy must permit <code>sts:AssumeRole</code> against that role ARN. Then check the external ID value and any SCP or permissions boundary in the calling account.</li>

<li><strong>A principal in the policy displays as AROA followed by random characters.</strong> The referenced role was deleted. Recreating it under the same name will not restore access.</li>

<li><strong>A Lake Formation share appears in the catalog but queries fail.</strong> Usually a missing resource link in the recipient account, or a RAM invitation that was never accepted.</li>

<li><strong>Access worked yesterday and stopped today with nothing changed locally.</strong> Look at the organization layer before re-reading the bucket policy. A newly attached RCP or SCP denies in a way that no amount of resource-policy editing will fix.</li>
</ul>



<h2 class="wp-block-heading">Common mistakes</h2>



<ul class="wp-block-list">
<li>Granting to an account principal when a role ARN would have done, then never revisiting it.</li>

<li>Treating <code>aws:PrincipalOrgID</code> as least privilege. It narrows the organization, not the account.</li>

<li>Sharing a bucket and forgetting the key, then debugging S3 for an hour over a KMS denial.</li>

<li>Referring to KMS keys by alias in cross-account configuration instead of by full ARN.</li>

<li>Letting a partner push into your bucket without confirming the ownership setting first.</li>

<li>Exporting a full table when the consumer needed four columns, because the export was easier to write.</li>

<li>Creating a vendor role with no external ID and no conditions because the vendor&#8217;s setup guide said to.</li>

<li>Granting access with no expiry, no owner, and no note in the policy Sid explaining why it exists.</li>
</ul>



<h2 class="wp-block-heading">Best practices worth the effort</h2>



<ul class="wp-block-list">
<li>Give every cross-account grant a descriptive <code>Sid</code>. It is the only free documentation slot IAM offers, and future-you will read it.</li>

<li>Prefer one dedicated consumer role per relationship over a shared role used by several integrations. Revocation becomes a single delete.</li>

<li>Pair a specific principal with a broad condition, rather than relying on either alone.</li>

<li>Put policy checks in CI so a widened grant fails a pull request instead of a quarterly review.</li>

<li>Keep an inventory of active shares with an owner and a review date, even if it is a table in a repository README.</li>

<li>Enable an external access analyzer scoped to your organization and treat new findings as work, not noise.</li>

<li>Watch cross-region and internet data transfer when a consumer sits somewhere unexpected. Cost visibility tooling such as Vantage or CloudZero will attribute it faster than reading a bill.</li>
</ul>



<h2 class="wp-block-heading">Frequently asked questions</h2>



<h3 class="wp-block-heading">What is the safest way to share an S3 bucket with another AWS account?</h3>



<p class="wp-block-paragraph">Name a specific role ARN in the bucket policy, add an organization or account condition alongside it, scope the resource to a prefix rather than the whole bucket, and make sure the consumer&#8217;s identity policy is equally narrow. If the bucket is encrypted with a customer managed key, grant on the key as well. If the list of consumers is going to grow, move to access points rather than growing the bucket policy.</p>



<h3 class="wp-block-heading">Do I need both a bucket policy and an IAM policy?</h3>



<p class="wp-block-paragraph">For cross-account access, yes. The resource-based policy in the owning account and the identity-based policy in the calling account both have to allow the action. That is the single most common reason a correct-looking bucket policy still returns Access Denied.</p>



<h3 class="wp-block-heading">Why do I get Access Denied when the bucket policy clearly allows the other account?</h3>



<p class="wp-block-paragraph">Work through it in this order: the consumer&#8217;s identity policy, then the KMS key policy if the objects are encrypted with a customer managed key, then any access point policy in the path, then organization-level SCPs and RCPs. Denials from the organization layer are the ones people waste the most time on, because nothing in either account&#8217;s policies looks wrong.</p>



<h3 class="wp-block-heading">Can I share data with an account outside my AWS Organization?</h3>



<p class="wp-block-paragraph">You can, and organization conditions will not help you there. Use a named role ARN, an external ID on any assumed role, a tightly scoped resource, and a defined end date. Also confirm whether an RCP in your organization already blocks external principals for that service, because that is a common reason a partner grant refuses to work no matter how carefully it is written.</p>



<h3 class="wp-block-heading">Should I use S3 Access Points or Lake Formation?</h3>



<p class="wp-block-paragraph">It depends on the unit of sharing. If the consumer wants objects under a prefix, access points are simpler and cheaper to operate. If the consumer wants a table, and especially if you need column or row filtering, Lake Formation is the right tool and access points would force you to fake fine-grained control with prefix layout. Plenty of environments run both.</p>



<h3 class="wp-block-heading">How do I revoke cross-account access cleanly?</h3>



<p class="wp-block-paragraph">Remove the statement from the resource policy, remove the KMS key grant, and check for any access point, RAM share, or resource link that also carries permission. Then confirm with CloudTrail that the principal has stopped appearing. Revocation feels done long before it is done, which is exactly why an inventory of active shares pays for itself.</p>



<h3 class="wp-block-heading">Does cross-account access change anything about cost?</h3>



<p class="wp-block-paragraph">The request charges land on the bucket owner by default, and data transfer depends on whether the consumer is in the same region. A consumer that quietly starts scanning a large prefix from another region is a billing event as much as a security one, which is a good argument for reviewing access patterns and spend together rather than in separate meetings.</p>



<h2 class="wp-block-heading">The one thing to remember</h2>



<p class="wp-block-paragraph">Getting AWS cross-account data sharing right is less about knowing which service to reach for and more about noticing which grants are quietly larger than they read. An account principal delegates your guest list. An organization condition narrows the wrong dimension. A copy leaves your perimeter permanently and no policy edit brings it back.</p>



<p class="wp-block-paragraph">Grant references rather than copies, name principals rather than accounts, remember that the key is a second door, and put something in place that watches the perimeter for you. The cross-account grant worth worrying about is never the one that failed. It is the one that worked, and kept working, long after anybody was still looking at it.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">Need a second pair of eyes on your cross-account setup?</h2>



<p class="wp-block-paragraph">Most of the cross-account problems I see are not exotic. They are a policy written in a hurry two years ago that nobody has read since. If that sounds familiar, here is where I can help:</p>



<ul class="wp-block-list">
<li>Auditing existing bucket, key, and role trust policies to find grants that are wider than intended, and rewriting them to named principals with conditions.</li>

<li>Designing a producer and consumer pattern for a specific data handoff, including whether access points, Lake Formation, or a plain scoped role is the right shape.</li>

<li>Setting up IAM Access Analyzer with a sensible zone of trust and archive rules, so the findings list stays worth reading.</li>

<li>Adding policy checks to your pipeline so a widened grant blocks a merge rather than surviving to production.</li>

<li>Building an organization-level perimeter with resource control policies and service control policies without breaking the integrations you already depend on.</li>

<li>Untangling stubborn cross-account denials, including the KMS, access point, and organization-layer cases that hide well.</li>
</ul>



<p class="wp-block-paragraph">Send me a bucket policy, a trust policy, or the CloudTrail entry for the denial you cannot explain, and I will tell you what it actually permits.</p>



<div class="wp-block-buttons is-layout-flex wp-block-buttons-is-layout-flex">
<div class="wp-block-button"><a class="wp-block-button__link wp-element-button" href="https://www.upwork.com/freelancers/~01f15a912ad84a6620" target="_blank" rel="noreferrer noopener">Work with me on Upwork</a></div>
</div>
<p>The post <a href="https://john-nessime.com/blog/cloud-computing/aws-cross-account-data-sharing/">AWS Cross-Account Data Sharing Done Safely: Share Access, Not Copies</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>Where PHI Escapes: Building a Secure Medical Lab Data Pipeline on AWS</title>
		<link>https://john-nessime.com/blog/cloud-computing/medical-lab-data-pipeline-aws/</link>
		
		<dc:creator><![CDATA[John Nessime]]></dc:creator>
		<pubDate>Fri, 21 Aug 2026 09:00:00 +0000</pubDate>
				<category><![CDATA[Cloud Computing]]></category>
		<category><![CDATA[Cloud Security]]></category>
		<category><![CDATA[Compliance]]></category>
		<category><![CDATA[Data Engineering]]></category>
		<category><![CDATA[Amazon Comprehend Medical]]></category>
		<category><![CDATA[Amazon Macie]]></category>
		<category><![CDATA[Amazon S3]]></category>
		<category><![CDATA[AWS]]></category>
		<category><![CDATA[AWS HealthLake]]></category>
		<category><![CDATA[AWS KMS]]></category>
		<category><![CDATA[Business Associate Agreement]]></category>
		<category><![CDATA[CLIA]]></category>
		<category><![CDATA[Clinical Data]]></category>
		<category><![CDATA[Crypto Shredding]]></category>
		<category><![CDATA[Data Classification]]></category>
		<category><![CDATA[De-identification]]></category>
		<category><![CDATA[Dead Letter Queue]]></category>
		<category><![CDATA[ePHI]]></category>
		<category><![CDATA[FHIR]]></category>
		<category><![CDATA[Healthcare Cloud]]></category>
		<category><![CDATA[HIPAA]]></category>
		<category><![CDATA[HL7 v2]]></category>
		<category><![CDATA[Log Retention]]></category>
		<category><![CDATA[Multi-Account Strategy]]></category>
		<category><![CDATA[PHI]]></category>
		<category><![CDATA[Pipeline Design]]></category>
		<category><![CDATA[Pseudonymization]]></category>
		<category><![CDATA[S3 Object Lock]]></category>
		<guid isPermaLink="false">https://john-nessime.com/blog/?p=455</guid>

					<description><![CDATA[<p>Encryption is the part everyone gets right. The PHI sitting in your logs, dead letter queues and object key names is the part that fails an audit. A boundary-by-boundary guide to building a medical lab data pipeline on AWS that survives review.</p>
<p>The post <a href="https://john-nessime.com/blog/cloud-computing/medical-lab-data-pipeline-aws/">Where PHI Escapes: Building a Secure Medical Lab Data Pipeline on AWS</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">The question that breaks most healthcare data designs is not technical. It is an auditor asking you to list every place protected health information is stored. Someone points at the encrypted S3 bucket, the encrypted RDS instance, the encrypted backups, and says &#8220;those three.&#8221; Then you grep CloudWatch Logs and find an accession number, a date of birth, and a patient surname sitting inside a Python traceback with no retention policy set on the log group.</p>



<p class="wp-block-paragraph">That is the failure mode that actually bites when you build a medical lab data pipeline on AWS. Not the bucket you thought about. The seven places PHI ended up because a payload got logged, an error got queued, or a filename contained a medical record number.</p>



<p class="wp-block-paragraph">This post walks through the design boundary by boundary: where lab data crosses from one trust zone into another, what leaks at each crossing, and which control closes it. It is aimed at engineers who have been handed an HL7 feed and a compliance checklist and told to make them meet. I am an engineer and not your compliance counsel, so treat the regulatory references here as pointers to read the primary text yourself.</p>



<h2 class="wp-block-heading">Why laboratory data is a harder pipeline than it looks</h2>



<p class="wp-block-paragraph">Lab data has three properties that most ETL work does not.</p>



<p class="wp-block-paragraph">First, results get corrected. A preliminary result goes out, the analyzer is recalibrated, and a corrected result follows hours or days later. Your pipeline is not append-only in the way a clickstream is. It has to carry amendment semantics all the way through, and an analytics table that silently keeps the first value is worse than no table at all.</p>



<p class="wp-block-paragraph">Second, the wire format is old and the transport is older. Most laboratory information systems still emit HL7 version 2 messages, pipe-delimited segments carried over the Minimum Lower Layer Protocol, which is framed bytes over a raw TCP socket. AWS documents MLLP as the common transport standard for HL7 v2 interoperability in its Healthcare Industry Lens. It has no built-in authentication and no built-in encryption. Whatever security it has, you bolt on around it.</p>



<p class="wp-block-paragraph">Third, the identifiers are load-bearing. Accession numbers, specimen IDs and medical record numbers are how the lab reconciles anything, so PHI is not a column you can drop. It is structural, and it will try to escape into every metadata surface you own.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">Boundary one: getting messages out of the lab</h2>



<p class="wp-block-paragraph">The instrument or LIS sits on a network you probably do not control, run by a vendor who may not let you touch the configuration. That constrains the design more than anything on the AWS side.</p>



<p class="wp-block-paragraph">You get three realistic shapes, and the LIS vendor usually picks for you.</p>



<ul class="wp-block-list">
<li><strong>Live MLLP over a private link.</strong> An interface engine in the lab holds the TCP session and forwards over a Site-to-Site VPN or Direct Connect into a private subnet. Lowest latency, highest operational burden, because a dropped socket is a clinical incident and not a retry.</li>

<li><strong>Batch file drop over SFTP.</strong> The LIS writes flat files on a schedule and something picks them up. Far easier to reason about, far easier to audit, and acceptable whenever the downstream use is analytics rather than result delivery.</li>

<li><strong>An outbound API push.</strong> Newer systems will POST to an endpoint you expose. Best case, because you control authentication and you get a response code back, but rarest in practice.</li>
</ul>



<p class="wp-block-paragraph">Whichever shape you get, the same rule applies at this boundary: <strong>terminate the untrusted protocol as early as possible and convert to something you can authorize.</strong> A raw MLLP socket carries no identity. The moment those bytes land, wrap them in a message you can attribute, sign, and trace.</p>



<p class="wp-block-paragraph">Two things go wrong here. First, people treat a network tunnel as authentication. A VPN tells you the packets came from the lab&#8217;s network. It does not tell you which system sent them, or that the sender was authorized to send that patient&#8217;s results. Second, people assume consumer VPN products fill the gap. Tools like NordVPN or Surfshark are fine for your own admin laptop on untrusted wifi, but they carry no business associate agreement and they are not a substitute for AWS Site-to-Site VPN or AWS Client VPN on a clinical path.</p>



<p class="wp-block-paragraph">One piece of housekeeping that is cheap to do and expensive to skip: the business associate addendum. AWS makes this self-service through AWS Artifact, and it can be accepted across an entire organization. Only services on the current HIPAA Eligible Services Reference may hold PHI, and that list changes, so check it against the services you actually chose rather than your memory of it.</p>



<h2 class="wp-block-heading">Boundary two: the landing zone</h2>



<p class="wp-block-paragraph">Raw messages land in S3. This part is well documented, so I will focus on the parts people configure once and never verify.</p>



<h3 class="wp-block-heading">Make the bucket policy do the enforcing</h3>



<p class="wp-block-paragraph">Default bucket encryption means objects get encrypted. It does not mean a caller cannot write an object with different settings, and it does not stop a plaintext HTTP request. Two explicit denials cover the gap. The first protects the transport, the second protects the storage:</p>



<pre class="wp-block-code"><code>{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "DenyInsecureTransport",
      "Effect": "Deny",
      "Principal": "*",
      "Action": "s3:*",
      "Resource": [
        "arn:aws:s3:::lab-raw-inbound",
        "arn:aws:s3:::lab-raw-inbound/*"
      ],
      "Condition": {
        "Bool": { "aws:SecureTransport": "false" }
      }
    },
    {
      "Sid": "DenyWrongKey",
      "Effect": "Deny",
      "Principal": "*",
      "Action": "s3:PutObject",
      "Resource": "arn:aws:s3:::lab-raw-inbound/*",
      "Condition": {
        "StringNotEquals": {
          "s3:x-amz-server-side-encryption-aws-kms-key-id":
            "arn:aws:kms:REGION:ACCOUNT:key/KEY-ID"
        }
      }
    }
  ]
}</code></pre>



<p class="wp-block-paragraph">Use a customer managed KMS key, not the AWS managed one. The reason is not stronger cryptography, it is the key policy. A customer managed key gives you a second, independent authorization surface: even a principal with broad S3 permissions cannot read the object if the key policy does not grant them decrypt. That separation is what makes a blast radius argument credible to an assessor, and it is what makes crypto shredding possible later.</p>



<h3 class="wp-block-heading">Your object keys are metadata, and metadata leaks</h3>



<p class="wp-block-paragraph">This is the one I would put on a poster. Object contents are encrypted. Object <em>keys</em> are not. They show up in CloudTrail data events, S3 server access logs, S3 Inventory reports, bucket listings, Lambda event payloads, and every console screenshot anyone pastes into a ticket.</p>



<pre class="wp-block-code"><code># Leaks PHI into six places that are not the bucket
raw/2024/ORU_MRN4471902_SMITH_JOHN_A1C.hl7

# Same object, no identifiers in the key path
raw/dt=&lt;partition&gt;/src=lis01/msg=01HXYZ...ULID.hl7</code></pre>



<p class="wp-block-paragraph">Use an opaque, sortable identifier and keep the mapping from identifier to patient inside an encrypted store you actually control. Partition on ingest date and source system, never on anything derived from the patient. This one decision removes more PHI surface than any other single change in the pipeline.</p>



<h3 class="wp-block-heading">Immutability, and the trap inside it</h3>



<p class="wp-block-paragraph">Clinical laboratories in the United States operating under CLIA have record retention obligations set out in 42 CFR 493.1105. The floor for test reports is at least two years after the date of reporting, with pathology test reports at ten years, and several state requirements sit above the federal floor. Retention is therefore not something you invent; go and read the section, then check your state.</p>



<p class="wp-block-paragraph">S3 Object Lock gives you write-once-read-many enforcement, and it is the right tool. But understand the two modes before you commit, because one of them is genuinely irreversible.</p>



<ul class="wp-block-list">
<li><strong>Governance mode</strong> blocks deletion for most principals, but anyone holding <code>s3:BypassGovernanceRetention</code> can override it by sending the <code>x-amz-bypass-governance-retention:true</code> header. Note the S3 console includes that header by default.</li>

<li><strong>Compliance mode</strong> cannot be overridden by anyone, including the account root. Per AWS documentation, the only way to delete an object under compliance retention before its date expires is to delete the AWS account.</li>
</ul>



<p class="wp-block-paragraph">A typo in a retention date under compliance mode is permanent, and you pay to store the mistake for its full term. Object Lock also has to be enabled at bucket creation, alongside versioning. Start in governance mode, prove the retention values against real traffic, then move to compliance mode deliberately rather than as a default.</p>



<pre class="wp-block-code"><code># Indefinite hold for a specific object version, e.g. under litigation
aws s3api put-object-legal-hold 
  --bucket lab-raw-inbound 
  --key raw/dt=.../msg=01HXYZ.hl7 
  --legal-hold Status=ON</code></pre>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">Boundary three: where PHI escapes into your observability</h2>



<p class="wp-block-paragraph">Here is the section that justifies the post. Everything above is on the compliance checklist. This is the part that is invisible until an audit or an incident makes it visible.</p>



<p class="wp-block-paragraph">Your operational plane is a PHI store you did not declare. Specifically:</p>



<ul class="wp-block-list">
<li><strong>Application logs.</strong> One <code>print(record)</code> left in a parser during debugging writes patient demographics into CloudWatch Logs, where the default is often no expiry at all.</li>

<li><strong>Exception traces.</strong> Unhandled parse errors frequently include the offending segment in the message. That segment is the PHI.</li>

<li><strong>Step Functions execution history.</strong> If you pass message bodies between states as payloads, that content is retained in the execution record and readable by anyone who can view executions.</li>

<li><strong>Dead letter queues.</strong> The whole point of a DLQ is to keep the failed message. A DLQ full of unparseable HL7 is a PHI archive with a different access policy than the bucket you designed so carefully.</li>

<li><strong>Third-party monitoring.</strong> Anything shipped to an external platform leaves your boundary. Grafana Cloud, Datadog and equivalents are perfectly reasonable choices for infrastructure telemetry, but a log line containing PHI going to a vendor without an executed BAA is a disclosure, not a metric.</li>

<li><strong>Notifications.</strong> An SNS topic that emails an on-call engineer &#8220;failed to process record for J. Smith, DOB &#8230;&#8221; has just sent PHI to a mail server nobody reviewed.</li>
</ul>



<h3 class="wp-block-heading">The pattern that fixes it: pass pointers, not payloads</h3>



<p class="wp-block-paragraph">Adopt a single rule and enforce it in review: <strong>PHI moves by reference between components, never by value.</strong></p>



<ol class="wp-block-list">
<li>The ingest function writes the raw message to the encrypted bucket and emits only an opaque object key plus a message ID.</li>

<li>Every downstream state, queue and event carries that pointer. Nothing carries the record body.</li>

<li>Error handling logs the pointer and a failure class, never the content that failed.</li>

<li>A DLQ therefore contains pointers to failures, and reprocessing means re-reading from the bucket under the same key policy as everything else.</li>
</ol>



<p class="wp-block-paragraph">The cost of this pattern is a slightly noisier debugging experience. You cannot read the failing record straight out of the queue. You have to go and fetch it, with credentials, and that fetch is logged. That inconvenience is the control working.</p>



<h3 class="wp-block-heading">Then close the log surface itself</h3>



<p class="wp-block-paragraph">Log groups created implicitly by Lambda and other services often retain data indefinitely unless you set a policy. Find the ones nobody configured:</p>



<pre class="wp-block-code"><code># List log groups with no retention configured
aws logs describe-log-groups 
  --query 'logGroups[?retentionInDays==null].logGroupName' 
  --output text

# Set an explicit retention on one of them
aws logs put-retention-policy 
  --log-group-name /aws/lambda/hl7-ingest 
  --retention-in-days 30</code></pre>



<p class="wp-block-paragraph">Run the first command as a scheduled check, not a one-off. New functions create new log groups, and the default comes back every time someone deploys.</p>



<p class="wp-block-paragraph">Amazon Macie is worth pointing at your buckets as a detective backstop. It will not stop a leak, but it will tell you when something started landing where it should not.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">Boundary four: from identified data to analytics</h2>



<p class="wp-block-paragraph">At some point someone wants to query the data. This is the crossing where teams are most likely to convince themselves they have done something they have not.</p>



<p class="wp-block-paragraph">Amazon Comprehend Medical will detect PHI entities in unstructured clinical text through its <code>DetectPHI</code> operation, returning entity types such as name, address, identifier and date, each with a confidence score. It is a genuinely useful tool for narrative fields: pathology comments, specimen notes, microbiology free text.</p>



<pre class="wp-block-code"><code>aws comprehendmedical detect-phi 
  --text "Specimen received from the referring clinic on the stated date."</code></pre>



<p class="wp-block-paragraph">Now the part the marketing pages underplay, straight from the AWS developer guide: the entities Comprehend Medical detects <em>do not map one to one</em> to the identifier list specified by the Safe Harbor method, and AWS explicitly recommends additional human review or other methods to confirm accuracy for compliance use cases.</p>



<p class="wp-block-paragraph">Read that as: PHI detection is not de-identification. Under the HIPAA Privacy Rule, de-identification has two defined routes, Safe Harbor and Expert Determination. An NLP model with confidence scores is an input to either, not a substitute for either. If your analytics tier is meant to hold de-identified data, someone has to own that determination, and it will not be your pipeline.</p>



<p class="wp-block-paragraph">What works structurally:</p>



<ul class="wp-block-list">
<li>Put the analytics tier in a <strong>separate AWS account</strong> with its own KMS key. Not a separate bucket. A separate account, so the blast radius argument is enforced by IAM boundaries rather than by convention.</li>

<li>Make the flow <strong>one directional</strong>. The de-identification job reads from the identified side and writes to the analytics side. Nothing in the analytics account holds permissions pointing back.</li>

<li><strong>Replace identifiers with pseudonyms</strong> rather than deleting them, keeping the crosswalk in the identified account. This preserves your ability to join across results and to re-identify under a documented process, which the research use case usually needs.</li>

<li>Watch <strong>quasi-identifiers</strong>. Rare test panels, unusual reference ranges and precise timestamps re-identify people in small populations even after the obvious fields are gone. This is a statistics problem, not an IAM problem.</li>

<li>Use <strong>Lake Formation</strong> or equivalent for column and row filtering if analysts need partial access, so the restriction lives with the catalog rather than in each query tool.</li>
</ul>



<h2 class="wp-block-heading">Boundary five: sending results back out</h2>



<p class="wp-block-paragraph">Delivery is where clinical correctness and security pull against each other, and security usually loses quietly.</p>



<p class="wp-block-paragraph">The correction problem is the sharp edge. HL7 result messages carry a status that distinguishes preliminary, final and corrected results. If your delivery layer is idempotent on message ID alone, a corrected result with a new ID for the same observation will happily land beside the original instead of superseding it. Two conflicting values, both marked delivered, nobody alerted.</p>



<p class="wp-block-paragraph">Build the idempotency key from the identity of the <em>observation</em>, which typically means the placer or filler order identifier combined with the specific observation identifier, and carry the result status as a first-class field so a correction overwrites rather than appends. Keep every version, expose the current one.</p>



<p class="wp-block-paragraph">For the outbound path itself:</p>



<ul class="wp-block-list">
<li>Presigned URLs are convenient for report PDFs and they are also a bearer credential. Anyone holding the link has the document. Keep expiry short and treat generation as an auditable event.</li>

<li>If you expose a provider-facing portal, put a WAF in front of it. AWS WAF and Cloudflare are both credible here; the deciding factor is usually where the rest of your edge already lives, not the rule engines.</li>

<li>Never email a result. Email a notification that a result is available, behind authentication. The distinction sounds pedantic right up until someone forwards a mailbox.</li>
</ul>



<h2 class="wp-block-heading">Boundary six: deletion, and the conflict nobody plans for</h2>



<p class="wp-block-paragraph">Two obligations point in opposite directions. Retention rules say keep the record. Privacy rights and internal policy say be able to remove data. If you set compliance-mode Object Lock across everything, you have chosen one side without noticing.</p>



<p class="wp-block-paragraph">Resolve it by classifying before you lock. Records that fall under laboratory retention obligations go into the immutable tier with a retention period derived from the applicable rule. Derived artifacts, caches, intermediate parquet, analytics extracts and enrichment outputs do not belong there. They belong in buckets with lifecycle rules, and they are what you actually delete.</p>



<p class="wp-block-paragraph">Crypto shredding covers the middle ground: if a data set is encrypted under a key used for nothing else, scheduling that key for deletion renders the ciphertext unrecoverable without touching the objects. It is clean, it is verifiable, and it only works if you planned the key granularity up front. Retrofitting per-tenant or per-cohort keys onto a pipeline that used one key for everything is a full re-encryption exercise.</p>



<p class="wp-block-paragraph">Do not forget the endpoints. Instrument workstations, the interface engine box, and the laptop somebody used to test the parser all accumulate copies. Cloud controls do nothing for physical media, and secure erase tooling such as O&amp;O SafeErase exists for exactly that step in a decommissioning runbook.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">Troubleshooting a medical lab data pipeline on AWS</h2>



<h3 class="wp-block-heading">Messages arrive but nothing appears downstream</h3>



<p class="wp-block-paragraph">Check the DLQ first, then check whether the DLQ itself has a consumer. A surprising number of pipelines have a correctly configured dead letter queue that nobody monitors, so failures accumulate silently and the only symptom is a gap in the data. Alarm on DLQ depth greater than zero, not on a threshold.</p>



<h3 class="wp-block-heading">Access denied from a Lambda that has the right IAM policy</h3>



<p class="wp-block-paragraph">With a customer managed KMS key, IAM permission on S3 is only half the grant. The function&#8217;s role also needs decrypt permission on the key, and the key policy has to allow it. If you are crossing accounts, both the key policy and the role policy must permit the action. This is the intended behavior of the two-lock design, and it is the single most common support question on any pipeline built this way.</p>



<h3 class="wp-block-heading">Deletion fails on an object you are certain you should be able to delete</h3>



<p class="wp-block-paragraph">Check for a legal hold before you check retention. A legal hold has no expiry and is independent of the retention period, so an object can be past its retention date and still undeletable. Also confirm which mode the retention uses, because governance and compliance produce the same error to a caller without bypass permission.</p>



<h3 class="wp-block-heading">Counts do not reconcile with the LIS</h3>



<p class="wp-block-paragraph">Compare on observations, not messages. One message can carry several results, corrections create additional messages for the same observation, and a naive message count will diverge from the LIS by exactly the amount that matters clinically. Reconcile daily and alert on drift rather than investigating at quarter end.</p>



<h3 class="wp-block-heading">Timestamps drift between systems</h3>



<p class="wp-block-paragraph">HL7 v2 timestamps do not always carry a timezone offset, and analyzers are often set to local time with no daylight saving handling. Capture the offset at ingest from the source system configuration and store everything in UTC with the original string preserved. Recovering an ambiguous timestamp after the fact is unpleasant, and around a daylight saving transition it may not be possible at all.</p>



<h2 class="wp-block-heading">Common mistakes</h2>



<ul class="wp-block-list">
<li><strong>Treating &#8220;HIPAA eligible&#8221; as &#8220;compliant.&#8221;</strong> Eligibility means AWS will cover the service under the BAA. Configuration is still entirely yours.</li>

<li><strong>Putting identifiers in object keys, queue names, or log group names.</strong> Encrypted contents, plaintext metadata.</li>

<li><strong>Enabling compliance-mode Object Lock before validating retention values.</strong> There is no undo, and no support ticket that fixes it.</li>

<li><strong>Assuming automated PHI detection equals de-identification.</strong> AWS itself says the entity list does not map one to one to Safe Harbor identifiers.</li>

<li><strong>Running the analytics tier in the same account as identified data.</strong> A bucket boundary is a policy away from collapsing. An account boundary is not.</li>

<li><strong>Shipping application logs to an external platform without checking what the log lines contain.</strong> Test it by grepping your own log group for a known test patient name.</li>

<li><strong>Ignoring corrected results until an analyst notices.</strong> Amendment handling is a day-one requirement, not a phase two feature.</li>
</ul>



<h2 class="wp-block-heading">Best practices worth the effort</h2>



<ul class="wp-block-list">
<li><strong>Write the data flow diagram first and mark every trust boundary crossing.</strong> Every arrow that crosses one needs a named control. This exercise finds more problems than any scanner.</li>

<li><strong>Encode the eligible service list as policy.</strong> Service control policies at the organization level stop someone from putting PHI into a service you never assessed.</li>

<li><strong>Keep VPC endpoints on the PHI path.</strong> Interface and gateway endpoints keep traffic to AWS services off the public internet and give you an endpoint policy as an extra choke point. Check which of your chosen services offer one.</li>

<li><strong>Build with synthetic HL7 from day one.</strong> Nobody should need production PHI to develop a parser. A cheap VPS from Contabo or InterServer is fine for a synthetic-data development box, and keeping that environment entirely outside the PHI boundary is the point.</li>

<li><strong>Alarm on absence.</strong> A feed that stops is more dangerous than a feed that errors, because errors are loud. Alert when expected message volume for a source drops below its floor.</li>

<li><strong>Test restores, not backups.</strong> Restore into an isolated account, confirm the KMS grants work there, and document how long it took.</li>

<li><strong>Review CloudTrail for data events on the PHI buckets.</strong> Object-level logging costs money and is the only record of who read what.</li>
</ul>



<h2 class="wp-block-heading">FAQ</h2>



<h3 class="wp-block-heading">Do I need a BAA with AWS before I start building?</h3>



<p class="wp-block-paragraph">Before PHI touches the environment, yes. You can architect and test with synthetic data first. AWS provides the business associate addendum through AWS Artifact as a self-service acceptance that can be applied across an organization, and only services listed on the HIPAA Eligible Services Reference may process, store or transmit PHI.</p>



<h3 class="wp-block-heading">Should I use AWS HealthLake or build on S3, Glue and Athena?</h3>



<p class="wp-block-paragraph">HealthLake is a managed FHIR datastore with FHIR APIs, medical NLP and query built in, and it is the shorter path if your consumers speak FHIR. It does not natively ingest HL7 v2; AWS points to partner tooling or your own transformation for non-FHIR input. If your consumers are analysts with SQL and the data is structured lab results rather than clinical documents, an S3 and Athena lakehouse is simpler and easier to keep inside a boundary you control. Decide on the consumer, not the source.</p>



<h3 class="wp-block-heading">How long do I have to keep laboratory records?</h3>



<p class="wp-block-paragraph">For CLIA-regulated laboratories in the United States, 42 CFR 493.1105 sets the federal floor, including at least two years for test reports after the date of reporting and at least ten years for pathology test reports. State law frequently exceeds these minimums, so the applicable period is whichever is longer for your jurisdiction. Read the regulation and confirm with your compliance lead before you encode a number into a retention policy.</p>



<h3 class="wp-block-heading">Can Amazon Comprehend Medical de-identify data for me?</h3>



<p class="wp-block-paragraph">It can detect PHI entities and give you confidence scores, which is a strong starting point for redaction workflows. It does not by itself satisfy HIPAA&#8217;s de-identification standard. AWS documents that the detected entities do not map one to one to the Safe Harbor identifier list and recommends human review for compliance use cases. Treat it as a detector feeding a documented Safe Harbor or Expert Determination process.</p>



<h3 class="wp-block-heading">Is S3 Object Lock compliance mode required for lab records?</h3>



<p class="wp-block-paragraph">Not inherently. The argument for compliance mode is a threat model in which a compromised or malicious administrator could remove retention, not a regulation that names the mode. Governance mode plus tightly controlled bypass permissions and strong audit logging is a defensible position for many laboratories. Choose deliberately and write down the reasoning, because compliance mode commits you to the storage cost for the full term with no exit.</p>



<h3 class="wp-block-heading">Where does PHI most often leak in a pipeline that looks correctly configured?</h3>



<p class="wp-block-paragraph">Logs, dead letter queues, workflow execution histories, notification messages and object key names. All five are metadata surfaces that sit outside the storage layer everyone reviews. Grep your own log groups for a known test patient identifier and you will find out quickly whether your pipeline has the problem.</p>



<h3 class="wp-block-heading">Do I need a dedicated AWS account just for the lab pipeline?</h3>



<p class="wp-block-paragraph">Separate accounts for identified and de-identified data are worth it even at small scale, because the boundary is then enforced by IAM rather than by naming conventions. Whether the PHI workload also needs isolating from your other production workloads depends on who holds admin access there. If the answer is &#8220;broadly the same people,&#8221; separate it.</p>



<h2 class="wp-block-heading">The one thing to take away</h2>



<p class="wp-block-paragraph">A secure medical lab data pipeline on AWS is not defined by the encryption on its storage layer. Encryption is the easy part, and it is the part everyone gets right. What separates a design that survives an audit from one that does not is whether PHI can travel by value through your operational plane: into logs, into queue bodies, into workflow histories, into filenames, into alert emails.</p>



<p class="wp-block-paragraph">Move PHI by reference, keep identifiers out of every metadata surface, and put an account boundary between identified and de-identified data. Do those three things and the rest of the compliance work becomes documentation rather than redesign.</p>



<h2 class="wp-block-heading">Work with me on healthcare data pipelines</h2>



<p class="wp-block-paragraph">I design and review AWS data platforms for regulated workloads, and lab data is a specific enough problem that generic cloud advice tends to miss it. Things I can help with:</p>



<ul class="wp-block-list">
<li>Mapping every trust boundary in an existing pipeline and finding the PHI surfaces nobody documented, including logs, queues and object key schemes.</li>

<li>Designing HL7 v2 or file-drop ingestion into S3 with KMS key separation, VPC endpoints and bucket policies that enforce rather than suggest.</li>

<li>Building the identified and de-identified account split, including the one-way transformation job and the pseudonym crosswalk.</li>

<li>Getting amendment and correction handling right so corrected results supersede rather than duplicate, all the way to the analytics tables.</li>

<li>Retention and deletion design: Object Lock modes, lifecycle policies, crypto shredding key granularity, and the runbook that ties them together.</li>

<li>Observability that is useful without being a disclosure risk, including CloudWatch retention hygiene and safe alerting patterns.</li>
</ul>



<p class="wp-block-paragraph">If you have an architecture diagram, a Terraform plan, or a redacted sample of the messages you are receiving, send it over and I will tell you what I would change first.</p>



<div class="wp-block-buttons is-layout-flex wp-block-buttons-is-layout-flex">
<div class="wp-block-button"><a class="wp-block-button__link wp-element-button" href="https://www.upwork.com/freelancers/~01f15a912ad84a6620" target="_blank" rel="noreferrer noopener">Work with me on Upwork</a></div>
</div>
<p>The post <a href="https://john-nessime.com/blog/cloud-computing/medical-lab-data-pipeline-aws/">Where PHI Escapes: Building a Secure Medical Lab Data Pipeline on AWS</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></content:encoded>
					
		
		
			</item>
	</channel>
</rss>
