<?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>Cloud Security | John Nessime</title>
	<atom:link href="https://john-nessime.com/blog/tag/cloud-security/feed/" rel="self" type="application/rss+xml" />
	<link>https://john-nessime.com/blog/tag/cloud-security/</link>
	<description>Cloud, DevOps, Data &#38; AI — Built, Tested, Explained</description>
	<lastBuildDate>Thu, 29 Oct 2026 06:05:05 +0000</lastBuildDate>
	<language>en-US</language>
	<sy:updatePeriod>
	hourly	</sy:updatePeriod>
	<sy:updateFrequency>
	1	</sy:updateFrequency>
	<generator>https://wordpress.org/?v=7.1.4</generator>

<image>
	<url>https://john-nessime.com/blog/wp-content/uploads/2026/07/cropped-jn-32x32.png</url>
	<title>Cloud Security | John Nessime</title>
	<link>https://john-nessime.com/blog/tag/cloud-security/</link>
	<width>32</width>
	<height>32</height>
</image> 
	<item>
		<title>Salesforce AWS PrivateLink: The Failures Nobody Warns You About</title>
		<link>https://john-nessime.com/blog/technical-guides/salesforce-aws-privatelink/</link>
		
		<dc:creator><![CDATA[John Nessime]]></dc:creator>
		<pubDate>Thu, 29 Oct 2026 06:05:05 +0000</pubDate>
				<category><![CDATA[Cloud Computing]]></category>
		<category><![CDATA[Networking]]></category>
		<category><![CDATA[Technical Guides]]></category>
		<category><![CDATA[Web Security]]></category>
		<category><![CDATA[Architecture]]></category>
		<category><![CDATA[AWS]]></category>
		<category><![CDATA[Cloud Security]]></category>
		<category><![CDATA[Data Integration]]></category>
		<category><![CDATA[DNS]]></category>
		<category><![CDATA[Hyperforce]]></category>
		<category><![CDATA[Interface Endpoint]]></category>
		<category><![CDATA[Named Credentials]]></category>
		<category><![CDATA[Network Isolation]]></category>
		<category><![CDATA[Private Hosted Zone]]></category>
		<category><![CDATA[PrivateLink]]></category>
		<category><![CDATA[Rate Limiting]]></category>
		<category><![CDATA[Route 53]]></category>
		<category><![CDATA[Salesforce]]></category>
		<category><![CDATA[Salesforce Private Connect]]></category>
		<category><![CDATA[SNI]]></category>
		<category><![CDATA[TLS]]></category>
		<category><![CDATA[Transit Gateway]]></category>
		<category><![CDATA[VPC]]></category>
		<category><![CDATA[VPC Flow Logs]]></category>
		<guid isPermaLink="false">https://john-nessime.com/blog/?p=330</guid>

					<description><![CDATA[<p>Salesforce Private Connect over AWS PrivateLink does not fail loudly. It fails by working: green statuses, successful callouts, and packets still crossing the public internet because one DNS record was wrong. Here are the six failure families that actually bite, and how to prove your traffic is private.</p>
<p>The post <a href="https://john-nessime.com/blog/technical-guides/salesforce-aws-privatelink/">Salesforce AWS PrivateLink: The Failures Nobody Warns You About</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 ticket said the integration was &#8220;already private.&#8221; Someone had bought the license, clicked through the setup wizard, seen a green status in the org, and closed the epic. Six weeks later a security review pulled VPC flow logs and asked a simple question: why is this traffic still leaving through the NAT gateway?</p>



<p class="wp-block-paragraph">That is the failure mode worth understanding before you touch anything. A Salesforce AWS PrivateLink setup does not fail loudly. It fails by working. The callout succeeds, the payload arrives, the dashboard is green, and the packets went over the public internet anyway because one DNS record was wrong. Nothing in either console tells you.</p>



<p class="wp-block-paragraph">This post covers what actually breaks when you wire Salesforce to AWS over private connectivity: the DNS trap above, the throughput ceiling nobody budgets for, the TLS and port constraints, why your sandbox cannot rehearse the change, and how to prove after the fact that traffic really is staying on the AWS network.</p>



<h2 class="wp-block-heading">What Private Connect actually gives you</h2>



<p class="wp-block-paragraph">Salesforce Private Connect is the product name. AWS PrivateLink is the underlying mechanism. Salesforce runs a managed transit VPC in each supported AWS region, and you attach to it: either your VPC creates an interface endpoint pointing at their endpoint service, or their transit VPC creates an endpoint pointing at yours.</p>



<p class="wp-block-paragraph">Two things people consistently get wrong here.</p>



<p class="wp-block-paragraph">First, the direction naming is from the org&#8217;s point of view, not yours. <strong>Inbound</strong> means traffic entering Salesforce, so your AWS workload calling the Salesforce REST or Bulk API. <strong>Outbound</strong> means Salesforce calling out to something you host. If you are an AWS engineer, &#8220;inbound&#8221; is the one that feels like outbound to you. Half the confused meetings I have seen on this trace back to that single word.</p>



<p class="wp-block-paragraph">Second, Private Connect only builds the pipe. It does not move data. You still need Apex callouts, External Services, Platform Events, MuleSoft, Amazon AppFlow, or whatever else was already doing the work. If your integration is broken today, a private path will carry the broken requests privately.</p>



<blockquote class="is-layout-flow wp-block-quote-is-layout-flow"><p>Private connectivity is a network property, not an application feature. Nothing above layer 4 changes. Plan the rollout as a routing change, not an integration project.</p></blockquote>



<h2 class="wp-block-heading">Failure family one: DNS that quietly resolves to the public internet</h2>



<p class="wp-block-paragraph">This is the one that produced the ticket at the top, and it is by far the most common way a Salesforce AWS PrivateLink deployment ends up decorative.</p>



<p class="wp-block-paragraph">For the inbound case, your application calls a hostname like <code>yourorg.my.salesforce.com</code>. That hostname resolves publicly. Creating the interface endpoint does not change that. Unless something inside your VPC overrides the answer, the resolver returns Salesforce&#8217;s public addresses and your traffic takes the normal internet path. The endpoint sits there costing you money and carrying nothing.</p>



<p class="wp-block-paragraph">The override is a Route 53 private hosted zone, associated with the VPC that holds the endpoint, containing a record that points the org hostname at the endpoint. Salesforce&#8217;s own walkthrough names the zone <code>my.salesforce.com</code> and puts the org-specific record inside it.</p>



<p class="wp-block-paragraph">Three things have to line up or the override silently does nothing:</p>



<ul class="wp-block-list"><li><strong>The zone is associated with the right VPC.</strong> A private hosted zone answers only for VPCs it is attached to. Query it from anywhere else and the request falls through to public recursion.</li><li><strong>The VPC has DNS support and DNS hostnames enabled.</strong> Both attributes must be on, or the VPC resolver will not serve the zone at all.</li><li><strong>Callers actually use the VPC resolver.</strong> Workloads pinned to an external resolver, containers with a hardcoded <code>nameserver</code> line, or on-premises clients arriving over Direct Connect will bypass the zone entirely unless you front it with a Route 53 Resolver inbound endpoint.</li></ul>



<p class="wp-block-paragraph">There is a sharp edge in the other direction too. If a private hosted zone matches the queried name but contains no record of the right type, Route 53 returns NXDOMAIN rather than falling through to public DNS. So a half-finished zone does not degrade gracefully, it takes the hostname down. Creating the zone and the record in separate change windows is how teams turn a routing improvement into an outage.</p>



<p class="wp-block-paragraph">Verify from inside the VPC, not from your laptop:</p>



<pre class="wp-block-code"><code># Run this on an instance in the VPC associated with the private zone.
dig +short yourorg.my.salesforce.com

# You want RFC 1918 addresses from your endpoint subnets.
# Public addresses mean the private zone is not being consulted.

# Confirm which resolver answered:
dig yourorg.my.salesforce.com | grep -i "SERVER:"</code></pre>



<h2 class="wp-block-heading">Failure family two: the throughput ceiling nobody reads</h2>



<p class="wp-block-paragraph">Private Connect is rate limited, and the default entitlement is small enough to catch people out badly.</p>



<p class="wp-block-paragraph">Salesforce documents an initial license entitlement of 225 MB of data per hour, measured separately for inbound and outbound, with an add-on ceiling far higher. The allowance does not roll over. It resets on the hour.</p>



<p class="wp-block-paragraph">Now consider what a nightly Bulk API extract looks like. A few gigabytes pushed in a twenty minute window is entirely normal and completely incompatible with a 225 MB hourly bucket. The two directions behave differently when you hit the wall, which matters more than the number itself:</p>



<ul class="wp-block-list"><li><strong>Outbound is enforced technically.</strong> Exceed it and outbound traffic stops until the counter resets at the top of the next hour. Your Apex callouts start failing. This is a production incident.</li><li><strong>Inbound is enforced contractually.</strong> Salesforce documents that you are responsible for monitoring and enforcing the inbound limit yourself. Nothing throttles you at the network layer, but you are outside your agreement, and standard enterprise API limits still apply on top.</li></ul>



<p class="wp-block-paragraph">The outbound allowance is visible through the Limits API as <code>PrivateConnectOutboundCalloutHourlyLimitMB</code>, which is what makes it monitorable. Poll it, put it on a dashboard next to your other API limit metrics in Grafana or CloudWatch, and alert well before exhaustion rather than after the callouts fail.</p>



<p class="wp-block-paragraph">If your volume genuinely exceeds the entitlement, the honest options are buying more, reshaping the workload to spread across hours, or accepting that bulk movement stays on a different path and Private Connect covers only your latency-sensitive transactional traffic. That last one is a legitimate architecture, not a failure.</p>



<h2 class="wp-block-heading">Failure family three: TLS, SNI, and the port you cannot use</h2>



<p class="wp-block-paragraph">Private Connect inbound supports port 443 only. Port 8443 is explicitly not supported because Salesforce uses it for mutual TLS. If your integration depends on client certificate authentication on that port, this design does not cover it, and finding out during cutover is expensive.</p>



<p class="wp-block-paragraph">The subtler problem is SNI. Salesforce documents that Hyperforce requires callers to include the Server Name Indication extension in the TLS ClientHello so the right certificate can be served, and that clients calling a hostname outside the <code>*.my.salesforce.com</code> or <code>*.sandbox.my.salesforce.com</code> patterns will hit HTTPS errors. Browsers always send SNI. Older HTTP libraries, embedded clients, and some middleware stacks do not.</p>



<p class="wp-block-paragraph">The reason this bites specifically during a PrivateLink migration is that you are changing what the name resolves to while keeping the name identical. A client that was quietly relying on the public path&#8217;s behavior now hits a different frontend. Test the handshake explicitly:</p>



<pre class="wp-block-code"><code># From inside the VPC, forcing SNI to the org hostname.
openssl s_client -connect yourorg.my.salesforce.com:443 
  -servername yourorg.my.salesforce.com &lt;/dev/null

# Compare with SNI omitted. If this one fails and the above
# succeeds, your client library is the problem, not the network.
openssl s_client -connect yourorg.my.salesforce.com:443 &lt;/dev/null</code></pre>



<p class="wp-block-paragraph">On the outbound side the mirror image applies. Salesforce is the client calling your endpoint service, and it will validate your certificate against the hostname it was told to use. If your Network Load Balancer terminates TLS with a certificate whose subject alternative names do not include the private DNS name you registered, the callout fails on hostname verification. Issue the certificate for a name you control and that matches what the named credential targets.</p>



<h2 class="wp-block-heading">Failure family four: you cannot rehearse this properly</h2>



<p class="wp-block-paragraph">This one deserves its own section because it changes how you plan the work.</p>



<p class="wp-block-paragraph">Private connections are not copied from production into sandboxes. You recreate them. And the ability to recreate them depends on sandbox type:</p>



<ul class="wp-block-list"><li><strong>Full and Partial Copy sandboxes:</strong> you can create connections and provision them. This is where genuine rehearsal happens.</li><li><strong>Developer and Developer Pro sandboxes:</strong> you can create connections but cannot provision them.</li><li><strong>Scratch orgs and Developer orgs:</strong> create only, no provisioning.</li></ul>



<p class="wp-block-paragraph">If your organization only has Developer sandboxes, there is no lower environment where the private path can be exercised end to end. That is a procurement conversation, not an engineering one, and it needs to happen at design time. Budget for a Full or Partial Copy sandbox or accept that production is your first real test.</p>



<p class="wp-block-paragraph">Related trap: connections are packageable while unprovisioned, and unprovisioned connections do not consume a license. That is useful for shipping configuration through a deployment pipeline, but it also means a successful deploy proves nothing about connectivity.</p>



<h2 class="wp-block-heading">Failure family five: license arithmetic and connection identity</h2>



<p class="wp-block-paragraph">Each Private Connect license covers one provisioned connection in each direction. Each connection is a one-to-one mapping between an org ID and a VPC endpoint ID.</p>



<p class="wp-block-paragraph">Read that mapping carefully, because it is where multi-account AWS estates get expensive. Separate VPCs mean separate endpoints mean separate connections mean separate licenses. Separate orgs mean the same again. A design with three application VPCs across two orgs is not one connection, it is six.</p>



<p class="wp-block-paragraph">The architectural answer is usually a shared services or egress VPC that owns the endpoint, with the application VPCs reaching it through a Transit Gateway. You pay for one endpoint and route to it. This is the same centralized-endpoint pattern you would already use for interface endpoints to AWS services, and the reasoning is identical: endpoints are billed per attachment, so consolidate them.</p>



<p class="wp-block-paragraph">Four inbound connections consume four licenses. If you hold four licenses, you have used all of them and have none left for outbound. Count both directions before the renewal conversation.</p>



<h2 class="wp-block-heading">Failure family six: the AWS side of an outbound connection</h2>



<p class="wp-block-paragraph">For outbound, you are the service provider. Salesforce&#8217;s transit VPC creates the endpoint into your account, which means you have to build a proper endpoint service.</p>



<p class="wp-block-paragraph">The sequence, in the order that avoids rework:</p>



<ol class="wp-block-list"><li>Stand up a Network Load Balancer in front of your service, across at least two Availability Zones.</li><li>Create the VPC endpoint service backed by that NLB.</li><li>Copy the IAM role ARN from the Private Connect setup page in Salesforce for your region, and add it to the allowed principals on the endpoint service. Without this, Salesforce cannot connect and the failure looks like a generic timeout.</li><li>Register a private DNS name on the endpoint service and complete domain ownership verification.</li><li>Create the outbound connection in Salesforce using the endpoint service name, then sync it to pick up the AWS-side status.</li><li>Associate the connection with a named credential. Callouts only take the private path when they go through a named credential bound to that connection.</li></ol>



<p class="wp-block-paragraph">Step four is where people lose an afternoon. Domain verification requires a TXT record in a <em>public</em> zone, because AWS queries the public name servers registered at your domain&#8217;s registrar. Put the record in a private hosted zone and verification sits in pending forever with no useful error.</p>



<pre class="wp-block-code"><code># Register the private DNS name on your endpoint service.
aws ec2 modify-vpc-endpoint-service-configuration 
  --service-id vpce-svc-EXAMPLE 
  --private-dns-name "sf-integration.example.com"

# Read back the TXT record name and value AWS expects.
aws ec2 describe-vpc-endpoint-service-configurations 
  --service-ids vpce-svc-EXAMPLE 
  --query 'ServiceConfigurations[0].PrivateDnsNameConfiguration'

# After publishing the TXT record in your PUBLIC zone:
aws ec2 start-vpc-endpoint-service-private-dns-verification 
  --service-id vpce-svc-EXAMPLE</code></pre>



<p class="wp-block-paragraph">One detail worth knowing: if verification status later changes away from verified, new connection requests are refused while existing connections keep working. So a lapsed TXT record does not break you immediately. It breaks you the next time something reconnects, which is usually during an unrelated incident.</p>



<h2 class="wp-block-heading">What this costs, mechanically</h2>



<p class="wp-block-paragraph">Rates change, so model the mechanism rather than memorizing figures from a blog post.</p>



<ul class="wp-block-list"><li><strong>Interface endpoint hours.</strong> Billed per endpoint, per Availability Zone, per hour, whether or not traffic flows. A three-AZ deployment is three billable attachments.</li><li><strong>Data processing.</strong> Per GB through the endpoint, on a tiered scale that drops as regional volume rises.</li><li><strong>Cross-AZ transfer through interface endpoints.</strong> AWS removed this charge, so multi-AZ endpoint placement is a resilience decision rather than a cost trade-off.</li><li><strong>Cross-region.</strong> Still charged, in both directions, plus a per-hour fee on the endpoint service for each remote region with attached endpoints. Match your org region to your VPC region if you can.</li><li><strong>Provider-side infrastructure.</strong> For outbound, the NLB and its targets are your bill, not PrivateLink&#8217;s.</li><li><strong>The Salesforce license.</strong> Separate from all of the above, priced per connection.</li></ul>



<p class="wp-block-paragraph">Compare honestly against what you are replacing. If the alternative is a NAT gateway carrying the same traffic, NAT has both an hourly charge and a per-GB processing charge of its own, and the comparison is often closer than people assume. If the alternative is a site-to-site VPN or a commercial network overlay, the calculus changes again. PrivateLink wins on the security posture, not automatically on the invoice.</p>



<h2 class="wp-block-heading">Troubleshooting: proving the traffic is actually private</h2>



<p class="wp-block-paragraph">Do not trust a green status field. Prove it with evidence from the data path.</p>



<ol class="wp-block-list"><li><strong>Resolve the hostname from inside the VPC.</strong> Private addresses from your endpoint subnets, or nothing else matters.</li><li><strong>Check the endpoint state.</strong> <code>aws ec2 describe-vpc-endpoints</code> should show the endpoint available, with an ENI in each subnet you expect.</li><li><strong>Read VPC flow logs.</strong> Filter for your application&#8217;s source addresses and confirm the destination is the endpoint ENI, not a NAT gateway or internet gateway address. This is the actual proof.</li><li><strong>Check the security group on the endpoint.</strong> Interface endpoints carry security groups. If 443 is not permitted from your application subnets, connections hang rather than refuse, which reads as a Salesforce problem in your logs.</li><li><strong>For outbound, sync the connection in Salesforce</strong> before believing its status. The displayed state is the last polled AWS state, not live.</li><li><strong>Test the callout through the named credential.</strong> A direct callout to a hardcoded URL will not use the private path even when everything else is correct.</li></ol>



<p class="wp-block-paragraph">Two symptom patterns worth recognizing. Connections that hang and time out point at security groups or NLB target health. Connections that fail fast with a TLS error point at certificate naming or SNI. They are diagnosed in completely different places.</p>



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



<ul class="wp-block-list"><li>Treating the setup wizard&#8217;s success message as proof that traffic is private. It proves the control plane is configured.</li><li>Creating the private hosted zone in one change and the record in another, producing NXDOMAIN for the org hostname in between.</li><li>Publishing the domain verification TXT record in a private zone instead of the public one.</li><li>Forgetting the Salesforce IAM role in the allowed principals list on the endpoint service, then debugging it as a network problem.</li><li>Sizing the connection for peak batch throughput without checking the hourly data entitlement.</li><li>Assuming sessions locked to their originating IP address will keep working. That behavior is documented as unsupported with Private Connect, along with asynchronous callouts using Apex continuations.</li><li>Building one endpoint per application VPC and discovering the license count at renewal.</li><li>Leaving callouts on hardcoded endpoints instead of named credentials, so outbound traffic never enters the private path.</li></ul>



<h2 class="wp-block-heading">Best practices for a Salesforce AWS PrivateLink rollout</h2>



<ul class="wp-block-list"><li>Inventory every integration touching the org first, with direction, protocol, hostname, and peak volume per hour. The inventory determines the design.</li><li>Centralize endpoints in a shared services VPC and route from application VPCs through Transit Gateway rather than duplicating endpoints and licenses.</li><li>Put the endpoint in the same region as the org. Cross-region private connectivity works but gives up most of the latency benefit and adds transfer charges.</li><li>Enable VPC flow logs before cutover so you have a clean before-and-after picture. Ship them somewhere you can query, whether that is CloudWatch Logs Insights, Athena over S3, or a platform like Grafana Cloud or Datadog.</li><li>Alert on the outbound hourly limit as a percentage of entitlement, not on callout failures. By the time callouts fail you are already down.</li><li>Terraform the AWS side. The endpoint, the endpoint service, the allowed principals, the private hosted zone, and the records all belong in code. The Salesforce side is click-configured, so document it in the same repository as a runbook.</li><li>Keep a documented rollback: the change that reverts you to public routing is removing or disassociating the private hosted zone, and it should be a one-step action you have practiced.</li><li>Review the configuration on a schedule. Private routing is invisible in normal operation and easy to break with an unrelated DNS or VPC change.</li></ul>



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



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



<h3 class="wp-block-heading">Does Private Connect encrypt my traffic?</h3>



<p class="wp-block-paragraph">Your traffic is already encrypted in transit by TLS. What private connectivity changes is the path, not the cipher. The value is that packets stay on the AWS network rather than traversing the public internet, which removes an entire class of exposure and simplifies a lot of compliance conversations. It is not a substitute for TLS, and it does not authenticate anything.</p>



<h3 class="wp-block-heading">Can I use Private Connect to access the Salesforce user interface privately?</h3>



<p class="wp-block-paragraph">No. This is API-path connectivity for integrations. Browser access to the org is a separate concern, and if that is what you need, you are looking at conditional access policies, login IP ranges, or a corporate network solution rather than PrivateLink.</p>



<h3 class="wp-block-heading">What Salesforce features actually route through an outbound connection?</h3>



<p class="wp-block-paragraph">Salesforce documents outbound support for Apex callouts, External Services, Platform Events, Change Data Capture, CRM Analytics, the OData 4.01 adapter and custom adapter for Salesforce Connect, the Salesforce Connect SQL adapter for Amazon Athena, and Agent Actions backed by External Services. Inbound covers the supported public APIs. Anything not on the current list should be verified against the documentation before you design around it, because that list grows.</p>



<h3 class="wp-block-heading">Do I need Hyperforce for this?</h3>



<p class="wp-block-paragraph">Salesforce states that Private Connect works whether the org sits in first-party data centers or on Hyperforce. That said, Hyperforce brings its own requirements, particularly the SNI behavior described above, so confirm where your org actually runs before you start debugging TLS.</p>



<h3 class="wp-block-heading">How do I connect on-premises systems, not just AWS workloads?</h3>



<p class="wp-block-paragraph">Bring the on-premises network into AWS first with Direct Connect or a site-to-site VPN, then use the interface endpoint from there. The extra work is DNS: on-premises resolvers need a Route 53 Resolver inbound endpoint to see the private hosted zone, otherwise they resolve the org hostname publicly and you are back to the first failure family.</p>



<h3 class="wp-block-heading">Can I use one connection for several Salesforce orgs?</h3>



<p class="wp-block-paragraph">No. A connection is a one-to-one mapping between an org ID and a VPC endpoint ID, so each org needs its own provisioned connection and its own license, even if they all point at the same VPC.</p>



<h3 class="wp-block-heading">Is a VPN a reasonable alternative?</h3>



<p class="wp-block-paragraph">For Salesforce specifically, no. Salesforce does not terminate a customer VPN into the org, so PrivateLink is the mechanism on offer. A VPN or a commercial overlay such as NordLayer solves a different problem, which is getting your own users and sites onto your own network. Where those tools genuinely overlap is the on-premises leg, and even there Direct Connect is usually the better answer for sustained integration traffic.</p>



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



<p class="wp-block-paragraph">A Salesforce AWS PrivateLink deployment that is misconfigured looks exactly like one that works. Same green statuses, same successful callouts, same clean application logs. The only difference is which path the packets took, and the only place that difference is visible is your flow logs.</p>



<p class="wp-block-paragraph">So build the verification step into the project rather than the postmortem. Resolve the hostname from inside the VPC, read the flow logs, confirm the destination is the endpoint ENI, and keep the check running afterwards. Everything else in this post is detail. That one habit is what separates private connectivity from a line item that says private.</p>



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



<h2 class="wp-block-heading">Need help wiring Salesforce to AWS privately?</h2>



<p class="wp-block-paragraph">This is the kind of work where a second pair of eyes on the DNS and the flow logs saves a security review later. I help teams with:</p>



<ul class="wp-block-list"><li>Designing the endpoint topology, including centralized endpoints behind Transit Gateway so you are not buying a connection per VPC.</li><li>Building and verifying the Route 53 private hosted zone override, including Resolver inbound endpoints for on-premises callers.</li><li>Standing up the endpoint service side for outbound connections: NLB, allowed principals, private DNS name registration, and domain verification.</li><li>Proving traffic is genuinely private with VPC flow log analysis, before and after cutover.</li><li>Instrumenting the hourly data entitlement so you find out about throttling from a dashboard rather than from failed callouts.</li><li>Codifying the AWS side in Terraform or OpenTofu, with a documented rollback to public routing.</li></ul>



<p class="wp-block-paragraph">If you are mid-way through one of these and something is not behaving, send me the <code>dig</code> output from inside the VPC, a flow log sample, or your endpoint service configuration and I will tell you what I see.</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/salesforce-aws-privatelink/">Salesforce AWS PrivateLink: The Failures Nobody Warns You About</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>IAM Access Analyzer Unused Access: Removing Permissions Without Breaking Production</title>
		<link>https://john-nessime.com/blog/technical-guides/iam-access-analyzer-unused-access/</link>
		
		<dc:creator><![CDATA[John Nessime]]></dc:creator>
		<pubDate>Sat, 10 Oct 2026 06:00:52 +0000</pubDate>
				<category><![CDATA[Cloud Computing]]></category>
		<category><![CDATA[Cloud Security]]></category>
		<category><![CDATA[Technical Guides]]></category>
		<category><![CDATA[Access Advisor]]></category>
		<category><![CDATA[AWS]]></category>
		<category><![CDATA[Blast Radius]]></category>
		<category><![CDATA[CloudTrail]]></category>
		<category><![CDATA[Data Events]]></category>
		<category><![CDATA[Decommissioning]]></category>
		<category><![CDATA[Explicit Deny]]></category>
		<category><![CDATA[Finding Triage]]></category>
		<category><![CDATA[IAM]]></category>
		<category><![CDATA[IAM Access Analyzer]]></category>
		<category><![CDATA[Least Privilege]]></category>
		<category><![CDATA[Policy Generation]]></category>
		<category><![CDATA[Rollback Plan]]></category>
		<category><![CDATA[Trust Policies]]></category>
		<category><![CDATA[Unused Access Findings]]></category>
		<guid isPermaLink="false">https://john-nessime.com/blog/?p=782</guid>

					<description><![CDATA[<p>Unused access findings tell you what AWS did not record activity for, which is not the same as what your workload does not need. Here are the four blind spots in the underlying data, and a deny-before-delete procedure that lets you trim IAM permissions with a one-call rollback.</p>
<p>The post <a href="https://john-nessime.com/blog/technical-guides/iam-access-analyzer-unused-access/">IAM Access Analyzer Unused Access: Removing Permissions Without Breaking Production</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 finding said the role hadn&#8217;t touched Amazon S3 in ninety days. The policy granted it full S3 access. Removing that looked like free security work, right up until the nightly export job started failing and nobody could explain why, because the role <em>was</em> using S3, constantly, in a way the analyzer could not see.</p>



<p class="wp-block-paragraph">That gap is the whole problem with permission cleanup. IAM Access Analyzer unused access findings are genuinely useful, and they are also built on evidence with known holes in it. AWS says so in its own documentation. Treat a finding as proof rather than as a lead and sooner or later you take something down.</p>



<p class="wp-block-paragraph">This post covers what an unused access finding actually asserts, the four blind spots that turn a clean-looking finding into an outage, and a removal procedure that fails safe.</p>



<h2 class="wp-block-heading">What an unused access finding actually claims</h2>



<p class="wp-block-paragraph">An unused access analyzer is a separate analyzer type from the external access one. You create it per account or across an organization, and it produces four kinds of finding: unused roles, unused IAM user access keys, unused IAM user passwords, and unused permissions at the service and action level for principals that <em>are</em> active.</p>



<pre class="wp-block-code"><code>aws accessanalyzer create-analyzer 
  --analyzer-name unused-access-prod 
  --type ACCOUNT_UNUSED_ACCESS 
  --configuration '{"unusedAccess": {"unusedAccessAge": 90}}'</code></pre>



<p class="wp-block-paragraph"><code>unusedAccessAge</code> is the threshold in days beyond which access counts as unused, and it accepts 1 to 365. Use <code>ORGANIZATION_UNUSED_ACCESS</code> from a delegated administrator account. Unused access findings don&#8217;t vary by Region, so you don&#8217;t need one analyzer per Region.</p>



<p class="wp-block-paragraph">Here is the part that matters. The analyzer does not replay CloudTrail and reconstruct what your workload did. It reads IAM last accessed information, the same data behind the Access Advisor tab in the console. Everything the analyzer knows, and everything it doesn&#8217;t, comes from there. The blind spots below are last accessed blind spots, inherited wholesale.</p>



<h2 class="wp-block-heading">The four blind spots that break production</h2>



<h3 class="wp-block-heading">1. Action-level tracking doesn&#8217;t cover everything</h3>



<p class="wp-block-paragraph">Action last accessed information only exists for the services and actions AWS has explicitly added to the tracked list, and it covers management actions. The IAM console spells out one case in plain text: action last accessed information is not available for Amazon S3 data events.</p>



<p class="wp-block-paragraph">Think about what that means for a data pipeline. A role whose entire purpose is <code>s3:GetObject</code> and <code>s3:PutObject</code> against one bucket is doing object-level work all day. Bucket-level calls like <code>ListBucket</code> are management events and get tracked. The object-level calls that constitute the actual job are data events, and they sit outside action last accessed.</p>



<p class="wp-block-paragraph">AWS puts the warning in the documentation itself: do not make permissions decisions based solely on the absence of tracking information. Almost nobody reads that sentence.</p>



<h3 class="wp-block-heading">2. Denied attempts count as access</h3>



<p class="wp-block-paragraph">Service last accessed data includes <em>all</em> attempts to call an AWS API, not just successful ones. A role that has been hammering a service it has no permission for, failing every time, shows up as having used that service.</p>



<p class="wp-block-paragraph">This runs opposite to the first blind spot, and it is why a findings list can be simultaneously too aggressive and too timid. Dead permissions look alive because a misconfigured client keeps retrying against them, so you clean up around them and leave the real cruft in place.</p>



<h3 class="wp-block-heading">3. The calendar is longer than your window</h3>



<p class="wp-block-paragraph">A ninety-day tracking period is the common default and it is wrong for a predictable class of role:</p>



<ul class="wp-block-list">
<li>Disaster recovery and failover roles, exercised once or twice a year</li>

<li>Quarterly close, audit, and regulatory reporting roles</li>

<li>Annual key rotation or certificate renewal automation</li>

<li>Break-glass roles, which should be unused by design</li>

<li>Seasonal workloads that only wake up for a peak period</li>
</ul>



<p class="wp-block-paragraph">There is also a ceiling worth knowing. <code>unusedAccessAge</code> tops out at 365 days, while IAM reports last accessed activity for at least the trailing 400 days. The analyzer cannot be configured to span the full window the underlying data actually holds, so for anything on an annual cycle it will not settle the question on its own.</p>



<h3 class="wp-block-heading">4. Service-level use says nothing about resource-level need</h3>



<p class="wp-block-paragraph">A finding operates on services and actions. Your policy probably operates on services, actions, resources, and conditions. Knowing a role used <code>secretsmanager:GetSecretValue</code> last week does not tell you which secret, and narrowing the policy to a specific resource ARN based on a finding is a guess dressed up as evidence.</p>



<p class="wp-block-paragraph">This is where policy generation earns its place. It reads CloudTrail activity for a principal over a window and drafts a policy from what it observed, including resource-level detail where the log has it. Same caveat: policy generation does not identify action-level activity for data events either. Better first draft, not an oracle.</p>



<h2 class="wp-block-heading">A removal procedure that fails safe</h2>



<p class="wp-block-paragraph">The core idea: <strong>deny before you delete</strong>. An explicit deny is one API call to add and one to remove, effective immediately in both directions. Deleting a policy statement or a role is not symmetrical; restoring it means someone reconstructing intent under pressure. Use deny as a reversible dry run for deletion.</p>



<ol class="wp-block-list">
<li><strong>Sort findings by blast radius, not by count.</strong> An unused permission on a role assumed by one Lambda is a different risk from one on a role fifty engineers assume daily.</li>

<li><strong>Confirm the role&#8217;s activity independently</strong> before touching permissions.</li>

<li><strong>Attach a scoped deny as an inline policy on the role,</strong> leaving the existing allow untouched. An explicit deny wins over any allow, so this simulates removal exactly with none of the loss.</li>

<li><strong>Watch CloudTrail for denials attributed to that role.</strong> This is the observation window and it is the step people skip.</li>

<li><strong>Wait through at least one full business cycle,</strong> longer if the workload has monthly or quarterly rhythm.</li>

<li><strong>Only then edit the allow</strong> and remove the deny.</li>
</ol>



<p class="wp-block-paragraph">For step two, the cheapest independent check is the role&#8217;s own last-used timestamp:</p>



<pre class="wp-block-code"><code>aws iam get-role --role-name legacy-reporting-role 
  --query 'Role.RoleLastUsed'</code></pre>



<p class="wp-block-paragraph">That returns <code>LastUsedDate</code> and the <code>Region</code> where the role was last used. <code>LastUsedDate</code> is null if the role has not been used within the tracking period, which is not the same as never used. The Region field is quietly valuable: a supposedly dead role showing activity in a Region nobody works in is worth a conversation before a deletion.</p>



<p class="wp-block-paragraph">For step three, the deny is deliberately narrow. Deny the flagged actions on the flagged service and nothing else:</p>



<pre class="wp-block-code"><code>{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "CanaryDenyUnusedRedshift",
      "Effect": "Deny",
      "Action": "redshift:*",
      "Resource": "*"
    }
  ]
}</code></pre>



<p class="wp-block-paragraph">Give it a Sid you can grep for, because in six months somebody will find this policy and have no idea whether it is a canary or a control.</p>



<p class="wp-block-paragraph">For step four, you want CloudTrail records where the request failed on authorization. The field is <code>errorCode</code>, and the values that matter are <code>AccessDenied</code> plus the EC2-specific <code>Client.UnauthorizedOperation</code>. Filter on the assumed-role session issuer so you catch every session. CloudTrail Lake or Athena over your trail bucket both work.</p>



<p class="wp-block-paragraph">To make this an ongoing signal rather than a one-off query, wire a CloudWatch Logs metric filter on <code>AccessDenied</code> to a dashboard. Amazon Managed Grafana, self-hosted Grafana, or Grafana Cloud all render it fine. What matters is that it is broken down by principal, so a spike points at a role rather than at the account.</p>



<h2 class="wp-block-heading">Deleting the role itself has a separate trap</h2>



<p class="wp-block-paragraph">When you name a principal by ARN in a policy, IAM stores the underlying unique principal ID rather than the string. Delete that role, recreate it with the identical name, and you get a new unique ID. Every policy that referenced the old one now points at something that no longer exists. Nothing errors at deletion time. The break surfaces later, in another account, when a cross-account assume-role call starts failing and the trust policy on screen looks exactly correct.</p>



<p class="wp-block-paragraph">Before deleting a role, check the things a findings list will never tell you:</p>



<ul class="wp-block-list">
<li>Is it named as a principal in any resource policy, S3 bucket policy, KMS key policy, or another account&#8217;s trust policy?</li>

<li>Is it attached to an instance profile?</li>

<li>Is it managed by CloudFormation, Terraform, or OpenTofu? Deleting out of band leaves drift that surfaces on the next apply.</li>

<li>Is it a service-linked role? Those are owned by a service and have their own deletion procedure.</li>

<li>Is it the execution role for a Lambda function, ECS task, or Step Functions state machine that runs rarely?</li>
</ul>



<p class="wp-block-paragraph">Safe order: detach the permissions, leave the role and its trust policy in place, wait another cycle, then delete. An empty role is harmless. A missing one is not.</p>



<h2 class="wp-block-heading">Keeping the findings list small enough to read</h2>



<p class="wp-block-paragraph">A findings list nobody triages is worse than none, because it produces the appearance of coverage. Two levers keep it honest. The first is exclusions, which can skip whole accounts or skip principals by tag, and can be updated at any time:</p>



<pre class="wp-block-code"><code>aws accessanalyzer update-analyzer 
  --analyzer-name unused-access-prod 
  --configuration '{
    "unusedAccess": {
      "analysisRule": {
        "exclusions": [
          {"accountIds": ["111122223333"]},
          {"resourceTags": [{"lifecycle": "break-glass"}]}
        ]
      }
    }
  }'</code></pre>



<p class="wp-block-paragraph">Tag-based exclusion is the one to reach for. Sandbox accounts churn roles and generate noise with no security value; break-glass roles are supposed to look unused. Excluding by tag survives new accounts joining the organization. An account ID list does not.</p>



<p class="wp-block-paragraph">The second lever is archiving findings you have consciously accepted:</p>



<pre class="wp-block-code"><code>aws accessanalyzer update-findings 
  --analyzer-arn &lt;your-analyzer-arn&gt; 
  --ids &lt;finding-id&gt; 
  --status ARCHIVED</code></pre>



<p class="wp-block-paragraph">Archiving is a decision record, not a dismissal. Write down why somewhere durable, because the same finding will come back to a different person.</p>



<p class="wp-block-paragraph">Scope has a cost dimension too. Unused access analysis is billed on the number of IAM roles and users analyzed per month, per analyzer, so a second analyzer with a different tracking period doubles the principals counted. If you watch spend through Cost Explorer or a layer like Vantage or CloudZero, confirm where this line lands before widening scope organization-wide.</p>



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



<p class="wp-block-paragraph"><strong>No findings yet, or a finding that won&#8217;t clear after you fixed it.</strong> After creating or updating an analyzer it takes time for findings to appear, recent activity takes around four hours to reach last accessed data, and scans are periodic rather than instant. Give it a full cycle. If you use the CLI or API for last accessed reports, generate a fresh one; the old report will not update itself.</p>



<p class="wp-block-paragraph"><strong>The finding says unused but the team insists otherwise.</strong> Assume the team is right and the tracking has a hole. Check whether the work is data-plane activity, then whether the actions involved are on the tracked list at all. Absence of tracking data and absence of use look identical from the findings page.</p>



<p class="wp-block-paragraph"><strong>Your canary deny broke something unexpected.</strong> That is the canary working. Detach the inline policy, confirm the failures stop, then narrow the deny to specific actions rather than the whole service and run it again.</p>



<p class="wp-block-paragraph"><strong>Something broke but no AccessDenied appears in CloudTrail.</strong> Check whether the failing call is a data event. A trail logs management events by default; data event logging is opt-in per resource type and carries its own charges. No configured data events, no record of the denial.</p>



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



<ul class="wp-block-list">
<li>Treating a finding as evidence of non-use rather than absence of recorded use. Different claims.</li>

<li>Deleting the role in the same change as removing its permissions, collapsing two reversible steps into one that isn&#8217;t.</li>

<li>Setting the tracking period once and never revisiting it against the workload&#8217;s actual rhythm.</li>

<li>Cleaning up in the console while Terraform or CloudFormation remains the source of truth, so the next apply puts it all back.</li>

<li>Batching removals across many roles at once. When something breaks you have a bisect problem instead of a rollback.</li>
</ul>



<h2 class="wp-block-heading">Best practices for IAM Access Analyzer unused access cleanup</h2>



<ul class="wp-block-list">
<li>Tag principals with their expected usage cadence at creation time. It costs nothing then and answers the hardest question later.</li>

<li>Start with unused access keys and passwords on IAM users. Highest value, lowest risk removals in the whole list.</li>

<li>Route findings to EventBridge and into your ticket queue so cleanup is tracked work rather than a periodic heroic sweep.</li>

<li>Make the permissions change in code, apply it through the normal pipeline, and let the diff be the record.</li>

<li>Keep an AccessDenied dashboard permanently, not just during a cleanup. Cheapest early warning you can build for this class of change.</li>
</ul>



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



<h3 class="wp-block-heading">Does IAM Access Analyzer read my CloudTrail logs?</h3>



<p class="wp-block-paragraph">Not for unused access findings. Those come from IAM last accessed information. Policy generation is the feature that reads CloudTrail, and it is a separate operation run against a specific principal and time range.</p>



<h3 class="wp-block-heading">What tracking period should I set?</h3>



<p class="wp-block-paragraph">Ninety days is reasonable for interactive human roles and continuously running workloads, and too short for anything on a quarterly or annual cycle. The value is configurable from 1 to 365 days. The right answer is a property of your workload, not a security best practice number.</p>



<h3 class="wp-block-heading">Can a finding be wrong?</h3>



<p class="wp-block-paragraph">It can mislead in both directions. Permissions used only through data events can appear unused, and permissions that only ever produced denied attempts can appear used, because last accessed data counts all attempts rather than only successful ones.</p>



<h3 class="wp-block-heading">Do I need an analyzer in every Region?</h3>



<p class="wp-block-paragraph">No. Unused access findings do not change based on Region, so one per account, or one at the organization level, is enough. External access analyzers are the ones with a Region dimension.</p>



<h3 class="wp-block-heading">How is unused access analysis billed?</h3>



<p class="wp-block-paragraph">On the number of IAM roles and users analyzed per month, per analyzer. Scope is the cost lever, which is why exclusions matter for the bill and not just the noise. Check current published rates before enabling it organization-wide.</p>



<h3 class="wp-block-heading">Should I delete unused roles or just strip their permissions?</h3>



<p class="wp-block-paragraph">Strip first, delete later, in two separate changes. A role with no permissions has almost no attack surface, and keeping it alive preserves any principal references pointing at it while you find out if anything still cares.</p>



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



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



<p class="wp-block-paragraph">IAM Access Analyzer unused access findings answer a narrower question than the one you care about. They tell you what AWS did not record activity for. They do not tell you what your workload does not need, and they cannot tell you whether a permission that <em>is</em> used should be. The distance between those statements is where the outages live.</p>



<p class="wp-block-paragraph">The one thing worth remembering: deny before you delete. An explicit deny gives you the exact behaviour of a removal with a one-call undo, and that single habit turns permission cleanup from a change you have to be brave about into one you can be systematic about.</p>



<h2 class="wp-block-heading">Need help trimming IAM permissions without the outage</h2>



<p class="wp-block-paragraph">The risk in permission cleanup sits almost entirely in what the tooling cannot see. Things I can help with:</p>



<ul class="wp-block-list">
<li>Setting up account or organization-level unused access analyzers with tracking periods and tag-based exclusions that fit how your workloads run</li>

<li>Building the AccessDenied observability layer, from CloudTrail queries through metric filters to a dashboard the team will actually look at</li>

<li>Running staged deny-then-delete cleanups with the rollback path defined before anything changes</li>

<li>Auditing roles for cross-account principal references, instance profiles, and IaC ownership before deletion</li>

<li>Moving console-era IAM into Terraform or OpenTofu so the next cleanup is a reviewable diff</li>
</ul>



<p class="wp-block-paragraph">If you have a findings list you are not sure how to act on, send me the role&#8217;s policy JSON and the finding detail and I will tell you what I would check before touching it.</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/iam-access-analyzer-unused-access/">IAM Access Analyzer Unused Access: Removing Permissions Without Breaking Production</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>Amazon Macie PII Detection: The Buckets It Never Opened</title>
		<link>https://john-nessime.com/blog/cloud-computing/amazon-macie-pii-detection/</link>
		
		<dc:creator><![CDATA[John Nessime]]></dc:creator>
		<pubDate>Sun, 06 Sep 2026 06:00:00 +0000</pubDate>
				<category><![CDATA[Cloud Computing]]></category>
		<category><![CDATA[Compliance]]></category>
		<category><![CDATA[Data Engineering]]></category>
		<category><![CDATA[Amazon Data Firehose]]></category>
		<category><![CDATA[Amazon Macie]]></category>
		<category><![CDATA[Amazon S3]]></category>
		<category><![CDATA[AWS]]></category>
		<category><![CDATA[AWS KMS]]></category>
		<category><![CDATA[AWS Organizations]]></category>
		<category><![CDATA[Cloud Security]]></category>
		<category><![CDATA[Cost Optimization]]></category>
		<category><![CDATA[Data Classification]]></category>
		<category><![CDATA[Data Governance]]></category>
		<category><![CDATA[Data Lake]]></category>
		<category><![CDATA[Encryption]]></category>
		<category><![CDATA[EventBridge]]></category>
		<category><![CDATA[PII Redaction]]></category>
		<category><![CDATA[S3 Lifecycle Rules]]></category>
		<category><![CDATA[Security Hub]]></category>
		<category><![CDATA[Sensitive Data Discovery]]></category>
		<guid isPermaLink="false">https://john-nessime.com/blog/?p=405</guid>

					<description><![CDATA[<p>A Macie bucket labeled "Not sensitive" often just means Macie never read it. Extensionless objects, unsupported storage classes, unreachable KMS keys and quota truncation all produce silence that looks identical to a clean result. Here's how to measure coverage, fix the four gaps, tune identifiers, and keep the bill honest.</p>
<p>The post <a href="https://john-nessime.com/blog/cloud-computing/amazon-macie-pii-detection/">Amazon Macie PII Detection: The Buckets It Never Opened</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 email came from legal, not from security. A customer had exercised a data access request, someone pulled the export by hand, and it contained email addresses and phone numbers sitting in a bucket Macie had labeled <em>Not sensitive</em>. Nobody had done anything wrong. The console was telling the truth as it understood it. It just didn&#8217;t understand very much about that bucket.</p>



<p class="wp-block-paragraph">That is the failure mode worth internalizing before anything else: Amazon Macie PII detection reports on what it managed to read, not on what is there. Objects it never opened produce no findings, and no findings looks identical to a clean result. The gap between &#8220;we scanned this&#8221; and &#8220;we found nothing&#8221; is where most Macie deployments quietly fail their first audit.</p>



<p class="wp-block-paragraph">This post covers where that gap comes from and how to close it: the eligibility chain every object goes through, the four distinct reasons an object gets skipped, how to tune identifiers so the findings are worth reading, what actually drives the bill, and how to route results somewhere a human will act on them.</p>



<h2 class="wp-block-heading">The eligibility chain behind Amazon Macie PII detection</h2>



<p class="wp-block-paragraph">Every object passes the same gates before a byte gets inspected. The order matters, because each gate fails differently and needs a different fix.</p>



<ol class="wp-block-list">
<li>Is it in a general purpose S3 bucket? Directory buckets are out of scope entirely.</li>



<li>Is the storage class supported? Standard, Standard-IA, One Zone-IA, Intelligent-Tiering, Glacier Instant Retrieval and Reduced Redundancy are in. Glacier Deep Archive and S3 Express One Zone are not.</li>



<li>Does the object key carry a recognized file extension? This is a string check on the name, not a content sniff.</li>



<li>Can Macie retrieve and decrypt it, given the bucket policy, object ACL and encryption key?</li>



<li>Does the content parse, and stay inside the per-file quotas?</li>



<li>Do the active data identifiers match anything in it?</li>
</ol>



<p class="wp-block-paragraph">Only the last gate produces a finding. Everything before it produces silence. Macie does record why each object was skipped, but that lives in coverage data and object samples, not on the findings page most people look at.</p>



<p class="wp-block-paragraph">One detail explains a lot of confusion: when you first enable automated sensitive data discovery, every bucket gets a sensitivity score of 50 and the label <em>Not yet analyzed</em>. A bucket whose permissions block Macie stays there permanently. It never turns red. It sits mid-list, looking unremarkable next to buckets that genuinely were analyzed.</p>



<h2 class="wp-block-heading">Gap one: objects Macie never opened</h2>



<p class="wp-block-paragraph">Macie calls these <em>unclassifiable</em>. They fail the storage class or extension check, so no retrieval is attempted. This is the largest source of false confidence I&#8217;ve seen, and the cheapest to fix.</p>



<h3 class="wp-block-heading">Missing file extensions</h3>



<p class="wp-block-paragraph">Classifiability comes from the file name extension. A file full of customer records named <code>part-00003-a4f9</code> is invisible. The same bytes named <code>part-00003-a4f9.json</code> get inspected and produce findings.</p>



<p class="wp-block-paragraph">This bites hardest on streaming ingestion. Amazon Data Firehose writes to S3 without appending an extension unless compression, format conversion, or the explicit file extension setting adds one. An uncompressed JSON stream lands as a tree of extensionless objects. Athena and Glue read them fine, because they infer format from the table definition. Macie skips every one.</p>



<p class="wp-block-paragraph">Fix it upstream: set the S3 file extension on the delivery configuration, or enable GZIP compression or Parquet conversion, both of which append a recognized extension. If you can&#8217;t change the producer, the fallback is a copy-and-rename step into a scan prefix, which costs you storage plus a pipeline to maintain. Fixing the producer is almost always cheaper.</p>



<h3 class="wp-block-heading">Unsupported storage classes</h3>



<p class="wp-block-paragraph">A lifecycle rule transitioning old exports to Glacier Deep Archive quietly removes them from scope. That may be the right storage decision, but it needs to be a conscious one, because archived customer data is still customer data when a regulator asks.</p>



<p class="wp-block-paragraph">Macie exposes these counts per bucket, split by cause. This is the first query I run against a new account:</p>



<pre class="wp-block-code"><code># Buckets where Macie can't classify objects, broken down by cause
aws macie2 describe-buckets 
  --query 'buckets[?unclassifiableObjectCount.total &gt; `0`].{
      bucket: bucketName,
      classifiable: classifiableObjectCount,
      skipped_total: unclassifiableObjectCount.total,
      skipped_extension: unclassifiableObjectCount.fileType,
      skipped_storage_class: unclassifiableObjectCount.storageClass
    }' 
  --output table</code></pre>



<p class="wp-block-paragraph">Read the ratio, not the raw number. Forty thousand classifiable objects and twelve skipped is fine. Three classifiable and four hundred thousand skipped is a reporting artifact pretending to be a scan result, and that bucket&#8217;s sensitivity score means nothing.</p>



<h2 class="wp-block-heading">Gap two: objects it opened and couldn&#8217;t read</h2>



<p class="wp-block-paragraph">These are classification errors rather than eligibility failures. Macie selected the object, tried to fetch it, and was refused. Three causes, and they need three different people to fix them.</p>



<ul class="wp-block-list">
<li><strong>Customer-provided keys (SSE-C).</strong> Macie cannot supply the key material, so it cannot retrieve the object. No permissions fix exists. Re-encrypt with S3 managed or KMS keys if you want coverage.</li>



<li><strong>KMS key policy.</strong> For customer managed keys, the key policy must allow the Macie service-linked role, <code>AWSServiceRoleForAmazonMacie</code>, to decrypt. Cross-account buckets need the key owner to grant it, not the bucket owner. AWS publishes a permission analyzer script in the <code>aws-samples/amazon-macie-scripts</code> repository that enumerates every key Macie needs and generates the CLI commands to fix them.</li>



<li><strong>Restrictive bucket policies.</strong> An explicit <code>Deny</code> conditioned on source IP or VPC endpoint blocks Macie along with everything else. The working pattern excludes the service-linked role ARN from the deny using the <code>aws:PrincipalArn</code> condition key.</li>
</ul>



<p class="wp-block-paragraph">To see which objects Macie actually touched in a bucket, pull the object samples. It&#8217;s the closest thing to a scan log you get:</p>



<pre class="wp-block-code"><code># Objects automated discovery selected, with per-object status
aws macie2 list-resource-profile-artifacts 
  --resource-arn arn:aws:s3:::your-bucket-name</code></pre>



<p class="wp-block-paragraph">Anything with a status of <code>SKIPPED</code> is a lead. Take the key, check its encryption settings in S3, and you usually have your answer inside a minute.</p>



<h2 class="wp-block-heading">Gap three: it read the file and nothing matched</h2>



<p class="wp-block-paragraph">Macie ships managed data identifiers covering common PII, financial data and credentials across many countries. Automated discovery uses a recommended subset by default rather than all of them. Sensible, but it is a default, and it will miss things that matter to you.</p>



<p class="wp-block-paragraph"><strong>Add the managed identifiers for your actual jurisdictions.</strong> If you hold records for customers in a country whose national ID identifier isn&#8217;t in the recommended set, you&#8217;re scanning for the wrong things with perfect efficiency. Pull the current list with <code>aws macie2 list-managed-data-identifiers</code> and compare it against where your customers live.</p>



<p class="wp-block-paragraph"><strong>Write custom identifiers for what only you know is sensitive.</strong> Internal account numbers, case references, employee IDs. A custom identifier is a regex plus optional keywords and a proximity rule, and the keywords are what save you. A bare <code>[0-9]{8}</code> matches timestamps, order totals and row counts, and you drown. The same regex with keywords and a match distance only fires when the number sits near a word that gives it meaning.</p>



<p class="wp-block-paragraph">Test the pattern before it goes near a job. This runs the criteria against sample text and returns match counts without creating anything:</p>



<pre class="wp-block-code"><code># Dry-run a custom identifier before creating it
aws macie2 test-custom-data-identifier 
  --regex 'ACC-[0-9]{4}-[0-9]{4}' 
  --keywords account customer acct 
  --maximum-match-distance 30 
  --sample-text 'customer account ACC-4821-9930 updated; total 1234-5678'</code></pre>



<p class="wp-block-paragraph">Run it against real samples, including the awkward ones, then against a file you know is clean and confirm zero. A pattern you only tested on positives is a pattern you haven&#8217;t tested.</p>



<p class="wp-block-paragraph">The mirror image is allow lists, which tell Macie to ignore specific text or patterns: your support inbox address, your published press contact, the seeded test records in everyone&#8217;s staging fixtures. Without them the same non-issues appear in every scan until the team stops reading findings. Allow lists accept predefined text or regex, and the per-account and per-job quotas are tight enough that you should curate rather than dump.</p>



<h2 class="wp-block-heading">Gap four: quota truncation inside large files</h2>



<p class="wp-block-paragraph">Subtle, because the object <em>is</em> analyzed. Just not completely, and a partial result reports like a full one.</p>



<ul class="wp-block-list">
<li>Per-file size quotas differ by format. Exceed the quota for a type and Macie analyzes none of that file, not part of it.</li>



<li>Archives have separate ceilings for nesting depth, extracted bytes and file count. If the metadata shows a breach up front, nothing is extracted. Cross a limit partway through and Macie stops, reporting only what it processed.</li>



<li>JSON and JSON Lines have a nested depth limit. Past it, the file is not analyzed at all.</li>



<li>Full names and mailing addresses cap out per file. After the cap Macie stops counting and stops reporting locations for that type, so the occurrence count is a floor, never a total.</li>
</ul>



<p class="wp-block-paragraph">Findings carry a status reason naming the limit that was hit, with values like <code>FILE_EXCEEDS_SIZE_LIMIT</code>, <code>ARCHIVE_NESTING_LEVEL_OVER_LIMIT</code> and <code>JSON_NESTING_LEVEL_OVER_LIMIT</code>. Filter on those before declaring a data lake of large compressed exports clean. Check current quota values in the Macie documentation rather than memorizing them, since they differ per format.</p>



<h2 class="wp-block-heading">Automated discovery or a discovery job?</h2>



<p class="wp-block-paragraph"><strong>Automated sensitive data discovery</strong> evaluates your inventory daily and samples representative objects across as many buckets as it can, grouping by metadata like prefix, extension and last-modified date. It is breadth-first and it does not re-analyze unchanged objects. Its job is to tell you which buckets deserve attention. It is not designed to prove a bucket is clean, and reading it that way is the root of most Macie misunderstandings.</p>



<p class="wp-block-paragraph"><strong>Sensitive data discovery jobs</strong> are the depth tool. You choose the buckets, scope by prefix, extension, size or object tag, choose the identifiers, and run once or on a schedule. This is what you point at the bucket automated discovery just flagged.</p>



<pre class="wp-block-code"><code># One-time job over a single prefix, recommended identifiers, 30% sampling
aws macie2 create-classification-job 
  --job-type ONE_TIME 
  --name "exports-prefix-review" 
  --managed-data-identifier-selector RECOMMENDED 
  --sampling-percentage 30 
  --s3-job-definition '{
    "bucketDefinitions": [
      {"accountId": "111122223333", "buckets": ["your-bucket-name"]}
    ],
    "scoping": {
      "includes": {
        "and": [
          {"simpleScopeTerm": {
             "comparator": "STARTS_WITH",
             "key": "OBJECT_KEY",
             "values": ["exports/"]
          }}
        ]
      }
    }
  }'</code></pre>



<p class="wp-block-paragraph">One detail about <code>--sampling-percentage</code> that trips people up: it selects a random percentage of eligible <em>objects</em> and then analyzes each selected object completely. It does not read a fraction of each file. For a bucket where every object comes from one pipeline, 20 or 30 percent tells you what you need. For heterogeneous uploads, sampling is a coin flip and you want the full pass.</p>



<h2 class="wp-block-heading">What actually drives the bill</h2>



<p class="wp-block-paragraph">Macie bills on three dimensions, and the one that surprises people is not the one they budget for.</p>



<ul class="wp-block-list">
<li><strong>Bucket evaluation.</strong> Per general purpose bucket monitored, prorated daily, up to the account ceiling. Predictable, driven by bucket count rather than data volume.</li>



<li><strong>Object monitoring.</strong> Per object tracked while automated discovery is enabled. This is inventory bookkeeping, not content reading, and it runs whether or not anything gets scanned that day. On an account with a log archive holding hundreds of millions of tiny objects, it can dwarf the other two.</li>



<li><strong>Data inspected.</strong> Per GB actually analyzed, covering both automated discovery and jobs.</li>
</ul>



<p class="wp-block-paragraph">So excluding buckets from automated discovery is a genuine cost lever, not just noise reduction. Log archives, CloudTrail destinations, build artifact stores and backup targets are usually safe exclusions. Exclude for the right reason though: a backup bucket holding database dumps of your customer table is exactly what you want scanned, however boring its name sounds.</p>



<p class="wp-block-paragraph">The three dimensions appear as distinct usage types in the Cost and Usage Report, so they split cleanly. A FinOps platform like Vantage or CloudZero, or just a Cost Explorer view grouped by usage type, tells you within a day whether object monitoring or inspection is driving the number. That changes the fix entirely: object monitoring is solved by scoping, inspection by sampling and scheduling.</p>



<h2 class="wp-block-heading">Getting findings somewhere a human sees them</h2>



<p class="wp-block-paragraph">Findings that only exist in the Macie console get read during onboarding week and never again. Two things are worth wiring up on day one.</p>



<p class="wp-block-paragraph"><strong>Publish findings outward.</strong> Macie emits findings to EventBridge and integrates with Security Hub. EventBridge earns its keep because you can filter on severity and finding type and route only what matters, whether that&#8217;s a Slack channel, a ticket, or a Lambda that tags the bucket. If you already centralize alerts in Grafana Cloud or similar, that path keeps sensitive data findings next to everything else on call sees.</p>



<p class="wp-block-paragraph"><strong>Configure the discovery results repository.</strong> Findings tell you what was found and where. The detailed discovery results, including analysis logs for objects where nothing was found, need an S3 bucket and a KMS key configured to persist. Nothing is retained long term until you set this up, and &#8220;we scanned it and found nothing&#8221; is exactly the record an auditor asks for.</p>



<p class="wp-block-paragraph">One operational note on the reveal feature, which retrieves sample occurrences so a human can confirm a finding is real. It needs a customer managed KMS key and it&#8217;s genuinely useful for triage. It also means someone is now looking at live customer PII, which belongs in your access model and audit trail. Same goes if an engineer downloads an object to investigate locally: that copy is unmanaged PII on a laptop and needs a secure deletion step, whether that&#8217;s a wipe tool from a vendor like O&amp;O Software or a documented and enforced process. Findings triage has a habit of creating the exposure it was meant to prevent.</p>



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



<h2 class="wp-block-heading">Troubleshooting a bucket that reports nothing</h2>



<p class="wp-block-paragraph">When Amazon Macie PII detection reports nothing for a bucket you have doubts about, work down this in order. Each step rules out one gate, and stopping at the first hit saves you the rest.</p>



<ol class="wp-block-list">
<li><strong>Check the sensitivity label.</strong> <em>Not yet analyzed</em> at score 50 means nothing was analyzed. Do not read that as clean.</li>



<li><strong>Check the coverage page.</strong> Macie names the issue directly: access denied, invalid encryption, invalid KMS key, permission denied, or unclassifiable.</li>



<li><strong>Compare classifiable and unclassifiable counts</strong> with the <code>describe-buckets</code> query above.</li>



<li><strong>List a few object keys.</strong> Run <code>aws s3 ls</code> against a prefix and look for extensions. Extensionless keys explain the whole thing.</li>



<li><strong>Check the storage class.</strong> A lifecycle rule may have moved everything somewhere Macie doesn&#8217;t read.</li>



<li><strong>Check encryption.</strong> SSE-C is unfixable without re-encryption. A customer managed KMS key is fixable with a key policy change.</li>



<li><strong>Check object samples</strong> with <code>list-resource-profile-artifacts</code> to see exactly what was selected and skipped.</li>



<li><strong>Only now question the identifiers.</strong> Run a targeted job with a custom identifier for the data you expect and see whether it fires.</li>
</ol>



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



<ul class="wp-block-list">
<li>Treating a low sensitivity score as evidence of no PII. It reflects what was found <em>and</em> how much was analyzed, so low coverage produces a low score.</li>



<li>Enabling Macie in one region and assuming account-wide coverage. Settings and results are per region, every time.</li>



<li>Running default identifiers in a business operating outside the jurisdictions the recommended set covers.</li>



<li>Building custom identifiers from regex alone, then abandoning Macie over the false positive volume.</li>



<li>Skipping the discovery results repository, then having no evidence trail when someone asks what was scanned and when.</li>



<li>Excluding buckets by name pattern without checking contents. Backup and export buckets are frequently the highest-risk ones you own.</li>
</ul>



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



<ul class="wp-block-list">
<li>Measure coverage before findings. A coverage report is the first deliverable of a Macie rollout, not an afterthought.</li>



<li>Fix extensions at the producer rather than building a rename pipeline downstream.</li>



<li>Run the KMS permission analyzer script once per account, then again whenever a new customer managed key appears.</li>



<li>Use automated discovery for breadth and targeted jobs for depth, letting the first choose targets for the second.</li>



<li>Test every custom identifier against both a positive and a negative sample before it reaches a job.</li>



<li>Curate allow lists early. The cost of a noisy first month is a team that stops reading findings.</li>



<li>Re-check coverage after any change to bucket policies, KMS keys, lifecycle rules or ingestion pipelines. All four silently remove data from scope.</li>
</ul>



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



<h3 class="wp-block-heading">Can Amazon Macie scan anything other than S3?</h3>



<p class="wp-block-paragraph">No. Macie analyzes objects in S3 general purpose buckets only. The documented workaround is bringing data to it: export RDS or Aurora snapshots to S3 in Parquet, or export a DynamoDB table to S3, then run a discovery job against the export. That works, but the export is now a second copy of your sensitive data with its own encryption and deletion requirements.</p>



<h3 class="wp-block-heading">Does Macie scan new objects as they land?</h3>



<p class="wp-block-paragraph">Not per object. Automated discovery evaluates inventory on a daily cycle and prioritizes objects that are new or recently changed, so fresh data moves up the queue rather than triggering an immediate scan. If you need scanning tied to arrival, drive a discovery job from S3 event notifications through EventBridge and accept that you own that orchestration.</p>



<h3 class="wp-block-heading">Why does a bucket I know has PII show a low sensitivity score?</h3>



<p class="wp-block-paragraph">Almost always coverage rather than detection. Check whether the objects are classifiable, whether Macie can decrypt them, and whether the label reads <em>Not yet analyzed</em>. If Macie genuinely read them and found nothing, the next suspect is the identifier set, and a targeted job with a custom identifier will tell you in one run.</p>



<h3 class="wp-block-heading">Do I need both automated discovery and discovery jobs?</h3>



<p class="wp-block-paragraph">For most teams, yes. Automated discovery gives you the map at predictable cost. Jobs give you proof for a specific bucket at a specific time, which is what compliance evidence actually requires. Running only jobs means you never discover the bucket nobody told you about.</p>



<h3 class="wp-block-heading">How do I cut false positives without missing real PII?</h3>



<p class="wp-block-paragraph">In order of preference: add keywords and a proximity rule so matches need context; use allow lists for specific known-benign values like your published contact details and test fixtures; and only then remove managed identifiers that are consistently wrong for your data. Removing identifiers is the bluntest option and the easiest to regret, so leave it last.</p>



<h3 class="wp-block-heading">Does Macie work across accounts in an organization?</h3>



<p class="wp-block-paragraph">Yes, through AWS Organizations with a delegated administrator. Design around this: the administrator&#8217;s automated discovery settings apply to member accounts, members can&#8217;t change them, and members see coverage and sensitivity data for their own buckets but not the sensitive data findings themselves. That shapes who can actually remediate what.</p>



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



<p class="wp-block-paragraph">Amazon Macie PII detection is only as good as its coverage, and coverage fails silently by design. An object with the wrong extension, the wrong storage class, or an unreachable encryption key produces no finding, and no finding renders identically to a clean result. Before you hand a Macie report to anyone who will make a decision from it, put the coverage numbers next to it: how many objects were classifiable, how many were analyzed, how many were skipped and why.</p>



<p class="wp-block-paragraph">Do that once and the rest of the service becomes straightforward. Skip it and you&#8217;re maintaining an expensive dashboard reporting on whichever subset of your data happened to be readable.</p>



<h2 class="wp-block-heading">Need help getting Macie to actually see your data?</h2>



<p class="wp-block-paragraph">Most of the work in a Macie rollout isn&#8217;t turning it on. It&#8217;s the unglamorous part: proving what got scanned, fixing the reasons things didn&#8217;t, and making the output land somewhere a human acts on it. That&#8217;s the part I help with.</p>



<ul class="wp-block-list">
<li>Coverage audits across your S3 estate, with per-bucket classifiable, analyzed and skipped counts and the specific cause of each gap</li>



<li>Fixing those causes: KMS key policies, bucket policies blocking the service-linked role, lifecycle rules archiving data out of scope, pipelines writing extensionless objects</li>



<li>Custom data identifiers and allow lists tuned against your real data and tested on positive and negative samples first</li>



<li>Cost shaping: working out whether object monitoring or data inspection drives your bill, and scoping discovery so the number is defensible</li>



<li>Findings pipelines through EventBridge or Security Hub into the alerting and ticketing you already run, with severity filtering that keeps signal above noise</li>



<li>Multi-account setup under AWS Organizations, including the delegated administrator model and who can see and remediate what</li>
</ul>



<p class="wp-block-paragraph">If you want a second opinion, send me the output of the <code>describe-buckets</code> query above, or a screenshot of your coverage page, and I&#8217;ll tell you what&#8217;s actually being scanned.</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/amazon-macie-pii-detection/">Amazon Macie PII Detection: The Buckets It Never Opened</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>Cognito MFA for Healthcare Apps: The Settings That Look Compliant but Aren&#8217;t</title>
		<link>https://john-nessime.com/blog/cloud-computing/cognito-mfa-healthcare-apps/</link>
		
		<dc:creator><![CDATA[John Nessime]]></dc:creator>
		<pubDate>Sun, 30 Aug 2026 13:00:00 +0000</pubDate>
				<category><![CDATA[Cloud Computing]]></category>
		<category><![CDATA[Compliance]]></category>
		<category><![CDATA[Web Security]]></category>
		<category><![CDATA[Account Recovery]]></category>
		<category><![CDATA[Adaptive Authentication]]></category>
		<category><![CDATA[Audit Logging]]></category>
		<category><![CDATA[AWS]]></category>
		<category><![CDATA[AWS Artifact]]></category>
		<category><![CDATA[Business Associate Agreement]]></category>
		<category><![CDATA[Cloud Security]]></category>
		<category><![CDATA[Cognito]]></category>
		<category><![CDATA[ePHI]]></category>
		<category><![CDATA[Healthcare Cloud]]></category>
		<category><![CDATA[HIPAA]]></category>
		<category><![CDATA[Identity Propagation]]></category>
		<category><![CDATA[MFA]]></category>
		<category><![CDATA[OIDC]]></category>
		<category><![CDATA[Passkeys]]></category>
		<category><![CDATA[PHI]]></category>
		<category><![CDATA[Session Management]]></category>
		<category><![CDATA[TOTP]]></category>
		<category><![CDATA[WebAuthn]]></category>
		<guid isPermaLink="false">https://john-nessime.com/blog/?p=348</guid>

					<description><![CDATA[<p>A user pool set to "MFA: Required" can still let clinicians in on a password alone. This is a practical guide to Cognito MFA for healthcare apps: why federated users bypass enforcement, how the account recovery collision locks people out, why required MFA and adaptive authentication cannot coexist, and how to make session lifetimes match what your documentation claims.</p>
<p>The post <a href="https://john-nessime.com/blog/cloud-computing/cognito-mfa-healthcare-apps/">Cognito MFA for Healthcare Apps: The Settings That Look Compliant but Aren&#8217;t</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 finding came back from a penetration test on a patient portal: the tester signed in as a clinical user with nothing but a username and password. No second factor. No prompt. Straight into a screen with appointment history on it.</p>



<p class="wp-block-paragraph">The user pool was set to <code>MFA: Required</code>. Somebody had screenshotted that console page for the security questionnaire. The screenshot was accurate. It just didn&#8217;t mean what everyone assumed it meant.</p>



<p class="wp-block-paragraph">That gap is the whole problem with Cognito MFA for healthcare apps. The service gives you a setting that reads like a guarantee, and then quietly carves out entire classes of users, sessions, and sign-in paths that the setting never touches. Nothing errors. Nothing logs a warning. Your compliance evidence looks fine right up until someone actually tests it.</p>



<p class="wp-block-paragraph">This post covers what the MFA switch in Amazon Cognito actually controls, the five failure families that show up in real healthcare deployments, how to wire a user pool so the enforcement matches the claim, and what to check when a clinician calls the help desk because they can&#8217;t get in.</p>



<h2 class="wp-block-heading">What HIPAA actually requires from MFA right now</h2>



<p class="wp-block-paragraph">Worth getting this straight before touching any configuration, because a lot of vendor content is confidently wrong about it.</p>



<p class="wp-block-paragraph">The HIPAA Security Rule as it stands today does not name multi-factor authentication. It requires person or entity authentication, and it leaves the mechanism to your risk analysis. MFA is how most people satisfy that in practice, but the rule does not spell it out.</p>



<p class="wp-block-paragraph">What changes the picture is the Notice of Proposed Rulemaking that HHS published in the Federal Register on January 6, 2025. It would make MFA an explicit requirement for access to systems handling ePHI, with narrow exceptions, and it would remove the &#8220;addressable&#8221; escape hatch that currently lets organizations document a control as infeasible. This one matters enough to name the date, because timelines have moved: the comment period closed in March 2025, the rule is still proposed, and the Unified Agenda has pushed anticipated final action out to 2027. That estimate is not binding and has already slipped once.</p>



<p class="wp-block-paragraph">Practical read: build for the proposed rule now, but don&#8217;t tell a client the mandate is already law. It isn&#8217;t, and auditors know the difference.</p>



<p class="wp-block-paragraph">On the AWS side, Amazon Cognito is on the HIPAA Eligible Services list and is covered by the AWS Business Associate Agreement. Eligibility is a starting line, not a finish line. You still have to accept the BAA in each AWS account that touches PHI, and you still have to configure the service correctly. Nothing about creating a user pool makes it compliant.</p>



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



<h2 class="wp-block-heading">Failure family one: half your users never see the MFA prompt</h2>



<p class="wp-block-paragraph">This is the one that produced the pen test finding, and it&#8217;s the most common serious gap I run into.</p>



<p class="wp-block-paragraph">Cognito user pool MFA applies to <em>local</em> users. Those are the accounts that live in the user pool directory and authenticate against it. For <em>federated</em> users, the ones arriving through SAML or OIDC from a hospital identity provider, Okta, Entra ID, or an in-house ADFS, Cognito hands the entire authentication decision to the upstream IdP. It does not add a second factor. It cannot. It never sees the password.</p>



<p class="wp-block-paragraph">So a portal with patients as local users and staff federated from a hospital directory has exactly the split you&#8217;d fear: patients get MFA, clinicians don&#8217;t, unless the hospital IdP is enforcing it independently. And the hospital IdP is usually somebody else&#8217;s system, run by a team you don&#8217;t have a Slack channel with.</p>



<p class="wp-block-paragraph">There&#8217;s a second version of this. The app client, not the user pool, controls which authentication flows are permitted. If a legacy mobile client is still allowed to use a flow you thought you&#8217;d retired, that path stays open regardless of what the pool-level MFA setting says.</p>



<p class="wp-block-paragraph">What to do about it:</p>



<ul class="wp-block-list"><li>Inventory every app client attached to the pool. Not the ones you remember creating. Run <code>list-user-pool-clients</code> and account for all of them.</li><li>For each federated IdP, get written confirmation of what the upstream enforces. A trust relationship is not a control. Put the answer in your risk analysis where an auditor will find it.</li><li>Treat federation as a boundary you inherit risk across, and say so out loud in design reviews. This is the single most useful sentence you can contribute to a healthcare identity project.</li></ul>



<h2 class="wp-block-heading">Failure family two: the recovery attribute collision</h2>



<p class="wp-block-paragraph">This one is genuinely subtle, and it doesn&#8217;t bite during development. It bites during rollout, at scale, to the people least able to tolerate it.</p>



<p class="wp-block-paragraph">Cognito refuses to let a user&#8217;s MFA delivery method be the same channel as their account recovery method. The logic is sound: if a password reset code and a second factor both land in the same inbox, an attacker with that inbox has everything, and the second factor is decoration.</p>



<p class="wp-block-paragraph">The consequence is where it gets sharp. If MFA is required, a user has only an email address and no phone number, email is your account recovery channel, and they have not registered a TOTP authenticator, then that user <strong>cannot complete sign-in at all</strong>. Cognito returns an error. They are not locked out temporarily. There is no path forward from the login screen.</p>



<p class="wp-block-paragraph">Now think about who that describes in a healthcare deployment. Part-time nursing staff enrolled from an HR export that had work email but no mobile number. Older patients who gave an email at registration and nothing else. Contractors. Locums. It is never a small number, and they all arrive at the help desk in the same week.</p>



<p class="wp-block-paragraph">The same rule cuts the other way on password reset. A user whose preferred MFA is email cannot receive a reset code by email. If they have no phone number, self-service recovery is gone, and someone with administrative credentials has to intervene for every single one.</p>



<p class="wp-block-paragraph">Three ways out, in the order I&#8217;d reach for them:</p>



<ul class="wp-block-list"><li><strong>Make TOTP the default factor.</strong> An authenticator app doesn&#8217;t consume either the email or phone attribute, so the collision never occurs. This is the one I reach for first in staff-facing apps.</li><li><strong>Require both email and phone_number as sign-up attributes.</strong> When a user has both, Cognito automatically routes reset codes to whichever channel isn&#8217;t their MFA factor. Clean, but it forces a phone number on people who may not want to give one.</li><li><strong>Configure a recovery fallback.</strong> Set account recovery to prefer email but fall back to SMS, so users who aren&#8217;t eligible for email recovery still have a route.</li></ul>



<p class="wp-block-paragraph">Whatever you choose, run a query against your existing user records for accounts missing a phone number before you flip MFA to required. Finding those users in a report is cheap. Finding them through a help desk queue is not.</p>



<h2 class="wp-block-heading">Failure family three: required MFA and adaptive authentication are mutually exclusive</h2>



<p class="wp-block-paragraph">This trips up almost every security-conscious design I review, because both features are things a healthcare client will ask for by name.</p>



<p class="wp-block-paragraph">Cognito&#8217;s threat protection includes adaptive authentication: risk scoring on each sign-in, with automated responses like requiring MFA or blocking outright. To use those automated responses in full-function mode, <strong>MFA must be set to optional in the user pool</strong>. That is a documented constraint, not a bug. The mechanism only makes sense if there&#8217;s a state where MFA is not already being demanded.</p>



<p class="wp-block-paragraph">So you cannot check both boxes. Either MFA is required for everyone and adaptive responses have nothing to escalate to, or MFA is optional and risk scoring can escalate, which means low-risk sign-ins may proceed on a password alone.</p>



<p class="wp-block-paragraph">For anything touching ePHI, I&#8217;d take required MFA and give up the adaptive escalation. A control that always applies beats a control that usually applies, and &#8220;usually&#8221; is a hard thing to defend in an audit.</p>



<p class="wp-block-paragraph">You don&#8217;t have to give up the visibility, though. Threat protection runs in audit-only mode alongside required MFA. You still get risk signals, device fingerprinting, and compromised credential detection surfaced in CloudWatch and in the user event logs, without the enforcement conflict. That&#8217;s a genuinely good trade: enforcement from the required setting, detection from threat protection.</p>



<p class="wp-block-paragraph">Two things to know before you budget for it. Threat protection lives in the Plus feature tier, and audit-only mode is billed the same as full-function. There&#8217;s no free observation period. Export those events somewhere you&#8217;ll actually look at them, whether that&#8217;s a CloudWatch Logs destination feeding your SIEM, or a platform like Datadog or Grafana Cloud if that&#8217;s where your on-call already lives.</p>



<h2 class="wp-block-heading">Failure family four: the session outlives the shift</h2>



<p class="wp-block-paragraph">MFA protects the moment of sign-in. It does nothing about the eight hours after it. In healthcare that gap is unusually expensive, because the workstation is shared, the browser stays open, and the next person to touch the keyboard is a different human being.</p>



<p class="wp-block-paragraph">Cognito&#8217;s defaults are built for consumer apps. Access and ID tokens last one hour. Refresh tokens last thirty days. Neither default is wrong in general and both are wrong for a clinical workstation.</p>



<p class="wp-block-paragraph">The ranges you have to work with:</p>



<ul class="wp-block-list"><li>Access token and ID token: five minutes to one day, set per app client.</li><li>Refresh token: sixty minutes to ten years, set per app client. It cannot be shorter than the access token lifetime.</li></ul>



<p class="wp-block-paragraph">Now the part that catches people, and the reason I put this failure family on the list. <strong>If you use managed login, Cognito sets a browser session cookie valid for one hour.</strong> Setting your access token to fifteen minutes does not shorten the re-authentication window. During that hour the user&#8217;s session refreshes without presenting credentials again, because the cookie is still good. Teams configure short tokens, write &#8220;15-minute session timeout&#8221; in the security documentation, and the observable behavior is an hour.</p>



<p class="wp-block-paragraph">The second trap is revocation. Revoking a token, or a global sign-out, invalidates refresh tokens. It does not invalidate an access token that has already been issued. That token stays valid until it expires, and any API accepting it will keep accepting it. So the practical blast radius of a compromised session is your access token TTL, not the moment you clicked &#8220;sign out this user.&#8221;</p>



<p class="wp-block-paragraph">That&#8217;s the argument for short access tokens: it is the only lever that shortens the window between &#8220;we revoked access&#8221; and &#8220;access actually stopped.&#8221;</p>



<pre class="wp-block-code"><code># Tighten an app client used by clinical staff.
# Access tokens live 15 minutes; refresh tokens expire at the end of a shift.
aws cognito-idp update-user-pool-client 
  --user-pool-id us-east-1_EXAMPLE 
  --client-id EXAMPLECLIENTID 
  --access-token-validity 15 
  --id-token-validity 15 
  --refresh-token-validity 9 
  --token-validity-units AccessToken=minutes,IdToken=minutes,RefreshToken=hours 
  --enable-token-revocation</code></pre>



<p class="wp-block-paragraph">Refresh token rotation is worth turning on where your client can support it. Each refresh returns a new refresh token and invalidates the old one, so a stolen refresh token is usable once at most. Two caveats that bite in practice: rotation does not extend the original lifetime, so a nine-hour refresh token still dies nine hours after sign-in no matter how often it rotated, and app clients with rotation enabled cannot use the older <code>REFRESH_TOKEN_AUTH</code> flow. That second one has broken more than one mobile release. Rotation requires the Essentials tier or higher.</p>



<p class="wp-block-paragraph">None of this replaces workstation-level controls. Screen lock policy, OS session timeouts, and endpoint hardening tooling like the utilities from O&amp;O Software do work your identity layer cannot do from the outside. If staff reach the admin console remotely, a managed VPN such as NordLayer or Surfshark reduces network exposure, but be honest in your documentation about what that is. It is not an authentication control, and treating it as one is exactly the reasoning auditors push back on.</p>



<h2 class="wp-block-heading">Failure family five: the factor you picked is phishable</h2>



<p class="wp-block-paragraph">Cognito offers SMS, email, and TOTP as second factors, plus passkeys as a first factor under specific conditions. They are not equivalent, and the console presents them as though they were.</p>



<p class="wp-block-paragraph"><strong>SMS</strong> is the weakest. SIM swap and SS7 interception are real, and an SMS code is trivially relayed through a phishing proxy. It has one genuine advantage worth naming: enrollment friction is near zero, and for elderly or low-technology-comfort patient populations that is not a small thing. An SMS factor that gets used beats a TOTP factor that gets abandoned. Just don&#8217;t put clinical staff behind it. There&#8217;s also an operational trap: if your account is in the Amazon SNS SMS sandbox in the relevant region, you must verify each destination number before delivery works, which turns a smooth rollout into a stalled one.</p>



<p class="wp-block-paragraph"><strong>Email OTP</strong> is roughly SMS-equivalent in phishing resistance and worse if the mailbox is on the same laptop as the session you&#8217;re protecting. It needs the Essentials tier or higher and a configured Amazon SES sending identity. Useful as a fallback, weak as a primary.</p>



<p class="wp-block-paragraph"><strong>TOTP</strong> is the sensible default for staff. It&#8217;s phishable, the code can be relayed in real time by an attacker-in-the-middle, but it&#8217;s immune to SIM swap and it doesn&#8217;t collide with your account recovery channel. Any standard authenticator works. If your organization already uses a password manager with built-in TOTP, such as 1Password or Bitwarden, enrollment gets meaningfully easier, at the cost of both factors living in one vault. That&#8217;s a trade-off to make deliberately rather than by accident.</p>



<p class="wp-block-paragraph"><strong>Passkeys</strong> are the only phishing-resistant option here, and their integration has a shape you need to understand. A passkey cannot be a second factor on top of a password in Cognito. It can satisfy the MFA requirement on its own, as a first factor, when you set <code>FactorConfiguration</code> to <code>MULTI_FACTOR_WITH_USER_VERIFICATION</code> in the pool&#8217;s WebAuthn configuration. The user verification step, biometric or device PIN, is what makes it count as multi-factor.</p>



<p class="wp-block-paragraph">That&#8217;s the path I&#8217;d push for privileged accounts: administrators, anyone with bulk record export, anyone who can change the pool configuration itself. Platform authenticators cover most staff; hardware keys like YubiKey are worth the cost for the handful of accounts whose compromise ends the conversation.</p>



<p class="wp-block-paragraph">One hard constraint to plan around: you cannot set MFA to required in a pool that allows email or SMS one-time passwords as a <em>first</em> factor. Passwordless OTP sign-in and required MFA are incompatible. Passkeys with user verification are the documented exception.</p>



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



<h2 class="wp-block-heading">Wiring up Cognito MFA for healthcare apps</h2>



<p class="wp-block-paragraph">An order of operations that avoids locking people out. Do this in a non-production pool first, with synthetic users only.</p>



<ol class="wp-block-list"><li><strong>Accept the BAA</strong> in the AWS account through AWS Artifact, before any real user data lands in the pool. The BAA is per-account and is not inherited by new accounts.</li><li><strong>Audit your user records</strong> for accounts missing <code>phone_number</code> or <code>email</code>. This is the population that will fail after you enable required MFA.</li><li><strong>Decide the recovery configuration</strong> and set it explicitly, with a fallback channel, so the MFA factor and the recovery channel can never be the same for any user.</li><li><strong>Enable MFA methods with the pool set to optional first.</strong> Let users enroll voluntarily. Track enrollment coverage as a number you report weekly.</li><li><strong>Flip to required only when coverage is high</strong> and the stragglers have a support path. Managed login will prompt unenrolled users to set up a factor at next sign-in, but only when MFA is required.</li><li><strong>Tighten token lifetimes per app client</strong>, then verify the observable behavior rather than the configuration. If you use managed login, remember the one-hour cookie.</li><li><strong>Turn on threat protection in audit-only mode</strong> and stream the events out to wherever you review security signal.</li></ol>



<p class="wp-block-paragraph">The core enforcement setting, once you&#8217;re ready:</p>



<pre class="wp-block-code"><code># Require MFA pool-wide, TOTP as the available method.
aws cognito-idp set-user-pool-mfa-config 
  --user-pool-id us-east-1_EXAMPLE 
  --mfa-configuration ON 
  --software-token-mfa-configuration Enabled=true

# Set a specific user's preferred factor administratively.
aws cognito-idp admin-set-user-mfa-preference 
  --user-pool-id us-east-1_EXAMPLE 
  --username clinician@example.org 
  --software-token-mfa-settings Enabled=true,PreferredMfa=true</code></pre>



<p class="wp-block-paragraph">Note that <code>admin-set-user-mfa-preference</code> does not reset an existing TOTP registration. To issue a new authenticator secret for a user who lost their phone, you go through <code>AssociateSoftwareToken</code> and <code>VerifySoftwareToken</code>. Build that runbook before you need it, because you will need it, and improvising an identity-proofing procedure at 2pm on a Tuesday is how bad help desk habits start.</p>



<p class="wp-block-paragraph">A staging pool holding only synthetic patients doesn&#8217;t need to live inside the BAA account, and there&#8217;s no reason a test environment should carry production&#8217;s cost structure. A plain VPS from InterServer or Contabo is fine for the app tier in front of a throwaway pool, provided you are certain no real PHI can reach it. That certainty is the whole condition, and it&#8217;s worth writing down rather than assuming.</p>



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



<h3 class="wp-block-heading">A user gets an error at sign-in and never reaches an MFA prompt</h3>



<p class="wp-block-paragraph">Almost always the recovery collision. Check whether the account has both <code>email</code> and <code>phone_number</code>, and compare the populated attribute against the pool&#8217;s account recovery setting. If the only available attribute is also the recovery channel, and there&#8217;s no TOTP registration, the user has no eligible factor. Register TOTP for them or add the missing attribute.</p>



<h3 class="wp-block-heading">Sign-in succeeds without MFA even though the pool says required</h3>



<p class="wp-block-paragraph">Work backward from the user, not from the pool. Is this a federated user? Then Cognito is not in the authentication path and pool MFA does not apply. Is it their first sign-in after account creation? Cognito treats the initial verification message as the second factor on that first sign-in and prompts for factor registration afterward. Is a different app client involved with different permitted flows?</p>



<h3 class="wp-block-heading">Password reset stopped working after enabling MFA</h3>



<p class="wp-block-paragraph">Expected behavior, not a regression. The reset code cannot go to the same channel as the user&#8217;s MFA factor. Users with only one contact attribute lose self-service recovery. Add the second attribute or configure a fallback recovery mechanism.</p>



<h3 class="wp-block-heading">SMS codes never arrive</h3>



<p class="wp-block-paragraph">Check the Amazon SNS SMS sandbox status in the region holding your SNS resources. In the sandbox, destination numbers must be verified in SNS before delivery. Also confirm the IAM role Cognito assumes for SNS is present and correctly scoped.</p>



<h3 class="wp-block-heading">Repeated MFA failures lock a user out</h3>



<p class="wp-block-paragraph">After five unsuccessful MFA code attempts, Cognito starts an exponential timeout lockout. It resolves on its own. Most real cases are clock drift on the user&#8217;s phone breaking TOTP validation, so check that before assuming an attack.</p>



<h3 class="wp-block-heading">Tokens keep working after you revoked them</h3>



<p class="wp-block-paragraph">Correct and by design. Revocation kills refresh tokens; already-issued access tokens survive to their expiry. If that window is unacceptable for your risk profile, shorten the access token TTL. There is no other lever short of checking revocation on every API call.</p>



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



<ul class="wp-block-list"><li>Treating the pool-level MFA setting as coverage for federated users. It is not, and this is the gap most likely to survive to production.</li><li>Storing PHI in Cognito custom attributes. Diagnosis codes, MRNs, and care team assignments do not belong in a directory record that gets copied into ID token claims, which then land in browser storage, proxy logs, and error reports. Keep the identity layer to identity.</li><li>Enabling required MFA in one deploy without an enrollment ramp. You will manufacture a support incident.</li><li>Assuming a short access token gives a short session when managed login is in play. The one-hour cookie governs re-authentication.</li><li>Believing revocation is immediate.</li><li>Leaving threat protection off because required MFA is on. Audit mode and required MFA coexist, and the detection signal is separately valuable.</li><li>Enabling refresh token rotation without checking whether your client uses <code>REFRESH_TOKEN_AUTH</code>. That combination is not permitted and it breaks at runtime.</li></ul>



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



<ul class="wp-block-list"><li>Separate app clients for patient-facing and staff-facing applications, with independently tuned token lifetimes. Staff sessions should not inherit consumer defaults.</li><li>TOTP as the baseline factor for anyone with access to more than their own record.</li><li>Passkeys with user verification for administrators and bulk-export roles.</li><li>Both <code>email</code> and <code>phone_number</code> collected at enrollment where the population allows it, purely to keep recovery working.</li><li>Access token TTL short enough that revocation means something. Fifteen minutes is a reasonable starting point for clinical apps.</li><li>Threat protection in audit mode, with events exported and actually reviewed.</li><li>Manage the pool in Terraform or OpenTofu so MFA settings, recovery configuration, and token lifetimes are reviewable in a diff instead of discovered in the console during an audit.</li><li>Test the MFA path in CI against a non-production pool. Configuration drift in an identity provider is silent by nature.</li></ul>



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



<h3 class="wp-block-heading">Is Amazon Cognito HIPAA compliant?</h3>



<p class="wp-block-paragraph">No service is HIPAA compliant by itself. Cognito is HIPAA <em>eligible</em> and covered under the AWS Business Associate Agreement, which means you may process PHI with it once you have accepted the BAA in that account and configured it appropriately. Compliance is a property of your whole system and your documentation, not of the service.</p>



<h3 class="wp-block-heading">Does HIPAA require MFA today?</h3>



<p class="wp-block-paragraph">Not explicitly. The current Security Rule requires person or entity authentication and leaves the method to your risk analysis. The proposed 2025 update would make MFA an express requirement, but it remains proposed and the timeline for final action has moved more than once.</p>



<h3 class="wp-block-heading">Can I use required MFA and adaptive authentication together?</h3>



<p class="wp-block-paragraph">Not in full-function mode. Adaptive authentication&#8217;s automated responses require MFA to be optional in the user pool. You can run threat protection in audit-only mode alongside required MFA, which keeps the detection signal without the conflict.</p>



<h3 class="wp-block-heading">Which MFA factor should healthcare apps use?</h3>



<p class="wp-block-paragraph">TOTP as the baseline for staff, passkeys with user verification for privileged accounts. SMS is defensible for patient populations where enrollment friction genuinely determines whether MFA gets used at all, but it should not protect clinical access.</p>



<h3 class="wp-block-heading">Does Cognito MFA apply to users signing in through our hospital SSO?</h3>



<p class="wp-block-paragraph">No. For federated users arriving via SAML or OIDC, Cognito delegates authentication entirely to the upstream identity provider and does not add a second factor. Enforcement has to happen at the IdP, and you should get that in writing.</p>



<h3 class="wp-block-heading">How short should session timeouts be for a clinical application?</h3>



<p class="wp-block-paragraph">Set access tokens short, commonly around fifteen minutes, and refresh tokens to roughly a shift length rather than the thirty-day default. Then verify what users actually experience, because managed login&#8217;s one-hour browser cookie can keep a session alive past your token settings.</p>



<h3 class="wp-block-heading">Can I store patient identifiers in Cognito user attributes?</h3>



<p class="wp-block-paragraph">You can, and I would avoid it. Custom attributes flow into ID token claims, which end up in browser storage, application logs, and error tracking. Keep an opaque internal identifier in the token and resolve clinical data from your application database behind its own authorization check.</p>



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



<p class="wp-block-paragraph">If you remember one thing about Cognito MFA for healthcare apps, make it this: the MFA setting is a claim about local users at the moment of sign-in, and nothing else. It says nothing about your federated clinicians, nothing about the session that continues for the next eight hours, and nothing about whether the factor you chose survives a phishing proxy.</p>



<p class="wp-block-paragraph">Verify enforcement by observing behavior, per user population, per app client. Not by screenshotting the console page. That screenshot is what got flagged in the pen test at the top of this post, and it was completely accurate.</p>



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



<h2 class="wp-block-heading">Need help with Cognito and healthcare authentication?</h2>



<p class="wp-block-paragraph">I work with teams building and hardening authentication on AWS, particularly where a compliance obligation makes &#8220;it works&#8221; insufficient. Things I can help with:</p>



<ul class="wp-block-list"><li>Auditing an existing user pool for the gaps in this post: federated coverage, recovery collisions, token lifetimes, app client sprawl</li><li>Designing an MFA rollout that won&#8217;t generate a help desk queue, including the pre-flight query against your user records and the enrollment ramp</li><li>Implementing passkeys with user verification for privileged accounts, alongside TOTP for general staff</li><li>Moving user pool configuration into Terraform or OpenTofu so identity settings are reviewable and drift is visible</li><li>Wiring threat protection events into your existing monitoring and building the alerts that are worth waking someone for</li><li>Writing the technical evidence that maps your configuration to your risk analysis, in language an auditor accepts</li></ul>



<p class="wp-block-paragraph">If you have a user pool that isn&#8217;t behaving the way the console suggests it should, send me the output of <code>describe-user-pool</code> and <code>describe-user-pool-client</code> with the identifiers stripped, and I&#8217;ll tell you what I see.</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/cognito-mfa-healthcare-apps/">Cognito MFA for Healthcare Apps: The Settings That Look Compliant but Aren&#8217;t</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>Building a Secure Client Document Vault Your Accounting Firm Can Defend</title>
		<link>https://john-nessime.com/blog/cloud-security/secure-client-document-vault-accounting-firm/</link>
		
		<dc:creator><![CDATA[John Nessime]]></dc:creator>
		<pubDate>Fri, 28 Aug 2026 09:00:00 +0000</pubDate>
				<category><![CDATA[Accounting Technology]]></category>
		<category><![CDATA[Cloud Security]]></category>
		<category><![CDATA[Compliance]]></category>
		<category><![CDATA[Accounting Firms]]></category>
		<category><![CDATA[Amazon S3]]></category>
		<category><![CDATA[Audit Logging]]></category>
		<category><![CDATA[AWS KMS]]></category>
		<category><![CDATA[Bucket Policy]]></category>
		<category><![CDATA[Client Onboarding]]></category>
		<category><![CDATA[Client Portal]]></category>
		<category><![CDATA[CloudTrail]]></category>
		<category><![CDATA[Crypto Shredding]]></category>
		<category><![CDATA[Data Classification]]></category>
		<category><![CDATA[Envelope Encryption]]></category>
		<category><![CDATA[GLBA Safeguards Rule]]></category>
		<category><![CDATA[Least Privilege]]></category>
		<category><![CDATA[Litigation Hold]]></category>
		<category><![CDATA[MFA]]></category>
		<category><![CDATA[Offboarding]]></category>
		<category><![CDATA[Presigned URLs]]></category>
		<category><![CDATA[S3 Lifecycle Rules]]></category>
		<category><![CDATA[S3 Object Lock]]></category>
		<category><![CDATA[Secure Disposal]]></category>
		<category><![CDATA[WISP]]></category>
		<guid isPermaLink="false">https://john-nessime.com/blog/?p=544</guid>

					<description><![CDATA[<p>Most accounting-firm document vaults do not fail at the bucket. They fail at the edges: a download link that outlives the engagement, a KMS key policy as wide as the bucket policy, a retention lock that cannot be undone, and an audit trail nobody switched on. Six failure families, and the control that closes each.</p>
<p>The post <a href="https://john-nessime.com/blog/cloud-security/secure-client-document-vault-accounting-firm/">Building a Secure Client Document Vault Your Accounting Firm Can Defend</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 ticket usually reads like this: <em>client says the download link expired, can you send a new one.</em> Thirty seconds of work. Generate a fresh link, paste it into a reply, close the ticket.</p>



<p class="wp-block-paragraph">Nobody looks at the old link. If it was signed with a long expiry and mailed out, it is still live: in the client&#8217;s inbox, in the inbox of the bookkeeper on the CC line, and in whatever mail archive that firm runs. It keeps working for anyone holding the URL until the clock runs out, because a presigned S3 URL is a bearer token. AWS says exactly that in its own documentation. The URL does not know who you are. It knows that you have it.</p>



<p class="wp-block-paragraph">That is the shape of most document vault failures at a small accounting firm. Not a wide-open bucket. Not a stolen partner password. An ordinary delivery mechanism that leaks quietly and writes no log line that looks wrong six months later.</p>



<p class="wp-block-paragraph">This post covers how to build a <strong>secure client document vault</strong> for an accounting practice: what the regulation actually demands, the six failure families that decide whether it holds, and the control that closes each one. The examples are AWS because that is where most of these land, but the failure families are platform independent.</p>



<h2 class="wp-block-heading">Let the regulation write your requirements</h2>



<p class="wp-block-paragraph">Most firms treat compliance as paperwork done after the build. That is backwards. For a US accounting or tax practice, the FTC Safeguards Rule at 16 CFR Part 314 is the closest thing you will get to a free requirements document.</p>



<p class="wp-block-paragraph">It treats tax preparers, bookkeepers and accounting firms as financial institutions under the Gramm-Leach-Bliley Act. Size does not get you out of it. The parts that translate directly into architecture:</p>



<ul class="wp-block-list">
<li><strong>Access controls</strong> limiting each user to the customer information they need, and limiting customers to their own information.</li>



<li><strong>Encryption</strong> in transit over external networks and at rest, with compensating controls only where encryption is infeasible and your Qualified Individual signs off in writing.</li>



<li><strong>Multi-factor authentication</strong> for any individual accessing any information system, subject to the same written-approval exception.</li>



<li><strong>Secure disposal</strong>, with an outer bound of two years after last use unless retention is required by law or necessary for legitimate business purposes.</li>



<li><strong>Logging of authorized user activity</strong>, plus a named Qualified Individual who owns the program.</li>
</ul>



<p class="wp-block-paragraph">One more clause should shape your design more than any other. The Rule requires notifying the FTC within thirty days of discovering a security event involving the unencrypted information of at least five hundred consumers, and it counts encrypted information as unencrypted if the key was accessed by an unauthorized person.</p>



<p class="wp-block-paragraph">Read that twice. Encryption only helps at the breach-analysis stage if the key was genuinely out of reach. That is the entire argument for treating key custody as a control separate from storage.</p>



<p class="wp-block-paragraph">Separately, IRS Publication 4557 expects paid preparers to keep a Written Information Security Plan, and PTIN renewal asks you to confirm you have one. Treat the WISP as the document describing the vault you built, not a substitute for building it.</p>



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



<h2 class="wp-block-heading">Failure one: the link that outlives the engagement</h2>



<p class="wp-block-paragraph">Presigned URLs are the sensible answer to &#8220;let the client download this without an AWS account&#8221;. The problem is their expiry semantics, which almost nobody has read.</p>



<p class="wp-block-paragraph">Two clocks apply and the shorter wins: the expiry you asked for, and the lifetime of the credential you signed with. Long-lived IAM user credentials under Signature Version 4 reach seven days. A role session from <code>AssumeRole</code> dies when the session ends, one hour by default, regardless of what you requested. An EC2 instance profile is bound by metadata credential rotation. The console caps at twelve hours.</p>



<p class="wp-block-paragraph">So the two symptoms are opposites and both are common. Links dying early is a credential problem. Links living too long is a policy problem, and that is the one that hurts.</p>



<p class="wp-block-paragraph">The fix is not a smaller number. It is changing what goes in the email.</p>



<ol class="wp-block-list">
<li>Email a notification, not the file link. The client logs in with MFA and the portal mints a URL at click time.</li>



<li>Keep that expiry in minutes. The URL only has to survive a redirect and a download.</li>



<li>Scope every URL to one object and one verb.</li>



<li>If you truly need a long-lived share, use CloudFront signed URLs or signed cookies, where you can revoke by rotating the key group without touching the object.</li>
</ol>



<pre class="wp-block-code"><code># Sign a download URL valid for five minutes.
# --expires-in takes seconds; the default is 3600.
aws s3 presign s3://firm-client-vault/clients/acme-llc/workpapers.pdf 
    --expires-in 300</code></pre>



<p class="wp-block-paragraph">Run that under an assumed role, wait past the session expiry, and watch it fail well before five minutes. That is behaviour worth seeing once in a test rather than discovering during an incident review.</p>



<h2 class="wp-block-heading">Failure two: everyone inside the firm sees everything</h2>



<p class="wp-block-paragraph">This shows up in nearly every small-firm build, and the reasoning is always the same. Nine people, everyone helps with everything during filing season, and compartmentalising access is friction exactly when friction is expensive.</p>



<p class="wp-block-paragraph">The case for flat access is real, so say it out loud: a scoped model people fight gets worked around, and a workaround is worse than the flat model you started with. If your access control makes a senior accountant email a PDF to themselves at 11pm, you have made things less safe.</p>



<p class="wp-block-paragraph">So scope by engagement rather than by person, and automate the grant so nobody files a request. Make the key prefix the boundary, then grant the prefix instead of the bucket. Two statements, two jobs: the first controls what someone can enumerate, the second what they can fetch.</p>



<pre class="wp-block-code"><code>{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "ListOnlyThisClient",
      "Effect": "Allow",
      "Action": "s3:ListBucket",
      "Resource": "arn:aws:s3:::firm-client-vault",
      "Condition": {
        "StringLike": { "s3:prefix": "clients/acme-llc/*" }
      }
    },
    {
      "Sid": "ReadWriteThisClient",
      "Effect": "Allow",
      "Action": ["s3:GetObject", "s3:PutObject"],
      "Resource": "arn:aws:s3:::firm-client-vault/clients/acme-llc/*"
    }
  ]
}</code></pre>



<p class="wp-block-paragraph">Without the prefix condition on <code>ListBucket</code>, a scoped user still gets a full inventory of your client list. Client names are confidential in this business. A directory listing that reads like a book of business is a disclosure even when no file opens.</p>



<p class="wp-block-paragraph">The other half of this family is standing administrative access. Somebody has to fix things, so somebody ends up with a permanent <code>s3:*</code> on the whole bucket. That principal is the highest-value target in the firm and usually has the weakest MFA, because it is a service account nobody wants to touch.</p>



<p class="wp-block-paragraph">Replace it with an assumable break-glass role: MFA required, short session, and an alert on every assumption. The alert is the point. Emergency access used twice a year is fine. Used weekly, it means your normal path is broken.</p>



<h2 class="wp-block-heading">Failure three: encrypted at rest, key sitting right next to it</h2>



<p class="wp-block-paragraph">Default bucket encryption gets ticked in week one and forgotten. It satisfies the letter of &#8220;encrypted at rest&#8221; and protects against exactly one threat: somebody walking out of a data centre with a disk. That is not your threat.</p>



<p class="wp-block-paragraph">Go back to the notification clause. If an attacker reaches a principal that can both read the object and use the key, your data is unencrypted for reporting purposes. The question is not &#8220;was the bucket encrypted&#8221;. It is &#8220;could whoever got in also use the key&#8221;.</p>



<ul class="wp-block-list">
<li>Use a customer managed KMS key. Only that gives you a key policy you control.</li>



<li>Name the principals allowed to call <code>Decrypt</code>, and keep that list shorter than the list allowed to call <code>GetObject</code>. Two different lists means an attacker needs two wins.</li>



<li>Use encryption context so grants can be conditioned per client, and so CloudTrail entries are readable later.</li>



<li>Enable S3 Bucket Keys to cut KMS request volume. KMS bills per API call, so a vault serving many small reads generates a surprising line item.</li>
</ul>



<p class="wp-block-paragraph">Encryption in transit needs its own control, because IAM policies say nothing about the connection. A bucket policy does:</p>



<pre class="wp-block-code"><code>{
  "Sid": "DenyPlaintextTransport",
  "Effect": "Deny",
  "Principal": "*",
  "Action": "s3:*",
  "Resource": [
    "arn:aws:s3:::firm-client-vault",
    "arn:aws:s3:::firm-client-vault/*"
  ],
  "Condition": {
    "Bool": { "aws:SecureTransport": "false" }
  }
}</code></pre>



<p class="wp-block-paragraph">An explicit deny beats every allow, including one you add by mistake later. Five lines that permanently remove a category of misconfiguration, which makes it the best value item on this page.</p>



<h2 class="wp-block-heading">Failure four: retention rules that fight your disposal duty</h2>



<p class="wp-block-paragraph">Here is the trap, and it is why I would not let an accounting firm switch on Object Lock without a conversation first.</p>



<p class="wp-block-paragraph">Object Lock has two retention modes. In governance mode, a principal holding <code>s3:BypassGovernanceRetention</code> can remove or shorten the lock. In compliance mode, nobody can. Not an administrator, not the account root user. AWS is explicit that the only way to delete a compliance-locked object before its retention date is to close the AWS account.</p>



<p class="wp-block-paragraph">Set that against the Safeguards Rule&#8217;s two-year outer bound on disposal of information you no longer need. Apply a blanket seven-year compliance-mode retention to everything landing in the vault and you have written a promise you cannot revoke, on documents you may later be obliged to destroy.</p>



<ol class="wp-block-list">
<li>Classify by document type before client. Filed returns, engagement letters and supporting workpapers have real statutory retention arguments. Raw intake uploads and scratch scans usually do not.</li>



<li>Default to governance mode. It stops accidental and casual deletion, which is what you need, while keeping a documented and audited escape hatch.</li>



<li>Give <code>s3:BypassGovernanceRetention</code> to almost nobody and alert on every use through CloudTrail.</li>



<li>Reserve compliance mode for records where an external mandate genuinely requires immutability and you chose the period deliberately.</li>



<li>Use legal hold, not a retention period, for litigation. A legal hold has no end date and stays on until explicitly removed, which is right when you cannot know how long a matter runs.</li>
</ol>



<p class="wp-block-paragraph">Two details catch people out. Object Lock requires versioning and normally has to be enabled at bucket creation, so it is a design decision rather than a retrofit. And lifecycle expiration will not delete a version still under retention, so lifecycle and Object Lock are two layers that must agree, not alternatives.</p>



<pre class="wp-block-code"><code># What lock configuration is this bucket actually running?
aws s3api get-object-lock-configuration --bucket firm-client-vault

# Freeze one object version for a matter with no known end date.
aws s3api put-object-legal-hold 
    --bucket firm-client-vault 
    --key clients/acme-llc/2023/return.pdf 
    --legal-hold Status=ON</code></pre>



<p class="wp-block-paragraph">For deletion itself, crypto-shredding is worth knowing. If a client&#8217;s documents sit under a per-client KMS key, scheduling that key for deletion renders every object unreadable without touching the objects. Clean, fast, provable, and irreversible once the waiting period ends, so it belongs behind two-person approval rather than in a script.</p>



<h2 class="wp-block-heading">Failure five: no evidence when somebody asks</h2>



<p class="wp-block-paragraph">Every control above is worth nothing if you cannot show it held. The Rule expects logging of authorized user activity and an annual written report from the Qualified Individual.</p>



<p class="wp-block-paragraph">The trap is that CloudTrail does not record S3 object-level reads and writes by default. Management events cover things like changing a bucket policy. Data events cover the actual <code>GetObject</code> calls, and they are opt-in and billed per event. Firms enable CloudTrail, see a trail in the console, assume they have a record of file access, then find during an incident that they have nothing at object level.</p>



<p class="wp-block-paragraph">Turn on data events for the vault bucket. At firm scale the volume is manageable, and it is the difference between &#8220;we believe two people opened that file&#8221; and being able to show it.</p>



<p class="wp-block-paragraph">Then decide where logs live. Access records for client financial data are themselves sensitive, so keeping them in the same account under the same administrators defeats the purpose. A separate logging account, or at minimum a separate bucket with its own key and retention, is the shape you want. If you already run Grafana Cloud or similar for infrastructure monitoring, pointing vault access logs there gives you dashboards and alerting without standing anything new up.</p>



<p class="wp-block-paragraph">The alerts worth having are boring and few: any use of the break-glass role, any change to the bucket or key policy, any bulk download outside normal working patterns, and any failed decrypt. Four alerts that fire almost never beat forty that everyone mutes.</p>



<h2 class="wp-block-heading">Failure six: the two ends of the pipeline</h2>



<p class="wp-block-paragraph">A vault only protects documents that are in it. The two ends are where firms quietly undo everything.</p>



<h3 class="wp-block-heading">Intake</h3>



<p class="wp-block-paragraph">If clients can still email a W-2 as an attachment, they will, because it is easier. Each attachment becomes customer information sitting in a mailbox, a sent-items folder and a mail archive, each with a different retention policy and access model than your vault.</p>



<p class="wp-block-paragraph">A policy memo will not fix this. Make the vault path faster than the email path: an upload link that works on a phone, no account creation for a one-off drop, and a mail rule that returns certain attachment types with the upload link instead. Then sweep the mailboxes you already have, because that backlog is real customer information and it counts.</p>



<h3 class="wp-block-heading">Offboarding</h3>



<p class="wp-block-paragraph">Seasonal staff are normal here, so offboarding happens in batches and gets rushed. Disabling the SSO login is the part everyone does. The parts that get missed:</p>



<ul class="wp-block-list">
<li>Active role sessions and refresh tokens, which survive the account being disabled until they expire on their own.</li>



<li>Presigned URLs that person generated, which stay valid because they are bound to the signing credential, not the login session.</li>



<li>Named principals still sitting in the KMS key policy.</li>



<li>Local copies on laptops and external drives. When hardware is retired, a certified erase tool such as O&amp;O SafeErase is the difference between disposal and hoping.</li>
</ul>



<p class="wp-block-paragraph">The same applies to service providers. The Rule makes you responsible for selecting providers capable of maintaining appropriate safeguards and holding them to it contractually. If an outsourced bookkeeping team touches your document workflow, that relationship is in scope, and direct network access means they need MFA too. Cloudflare Zero Trust or a business VPN such as NordLayer in front of the portal gives you somewhere to enforce that without rebuilding the application.</p>



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



<h2 class="wp-block-heading">Troubleshooting a secure client document vault</h2>



<ul class="wp-block-list">
<li><strong>Links expire far earlier than the expiry you set.</strong> You signed with temporary credentials that expired first. Sign from a principal whose credential outlives the link, or shorten the link and mint on demand.</li>



<li><strong>Uploads fail with access denied despite an allow on PutObject.</strong> With a customer managed key, both the IAM policy and the key policy must permit the caller. Check <code>kms:GenerateDataKey</code>, not just S3 actions.</li>



<li><strong>A file will not delete and no policy seems to block it.</strong> Look for Object Lock retention or a legal hold on that version. They are independent and either one alone blocks the delete.</li>



<li><strong>CloudTrail shows the bucket being created but no file access.</strong> Data events are not enabled. Management events will never show <code>GetObject</code>.</li>



<li><strong>A scoped user can see every client folder name.</strong> The prefix condition is missing from <code>ListBucket</code>, or was applied to the object ARN instead of the bucket ARN.</li>



<li><strong>Lifecycle rules appear to do nothing.</strong> Versions under retention are not expired by lifecycle. Confirm lock state before assuming the rule is wrong.</li>
</ul>



<h2 class="wp-block-heading">Common mistakes, and what to do instead</h2>



<ul class="wp-block-list">
<li><strong>Emailing the download link.</strong> Email a notification that a document is waiting, and mint the link after the client authenticates.</li>



<li><strong>Treating default bucket encryption as finished.</strong> Use a customer managed key and keep the decrypt list shorter than the read list.</li>



<li><strong>Granting bucket-wide list permissions.</strong> Condition <code>ListBucket</code> on the client prefix, or the roster leaks even when no document opens.</li>



<li><strong>Applying compliance-mode Object Lock broadly because it sounds safer.</strong> Write the retention schedule per document class first, get partners to sign it, then configure to match.</li>



<li><strong>Keeping a standing full-access principal.</strong> Replace it with an MFA-gated break-glass role that alerts on every assumption.</li>



<li><strong>Building a portal and leaving email intake open.</strong> Both channels then carry client data and only one is governed. Close the mail path and sweep the backlog.</li>



<li><strong>Storing access logs beside the data they describe.</strong> Ship S3 data events somewhere with different administrators and different keys.</li>



<li><strong>Never testing any of it.</strong> Verify a restore and a scoped-access denial quarterly. An unverified control is an assumption.</li>
</ul>



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



<h3 class="wp-block-heading">Does a small accounting firm really fall under the FTC Safeguards Rule?</h3>



<p class="wp-block-paragraph">Almost certainly, if it prepares returns or handles client financial data in the United States. The Rule&#8217;s definition of a financial institution covers activities financial in nature, and the FTC&#8217;s own examples include tax preparation firms. A narrow exception at 16 CFR 314.6 covers institutions holding information on fewer than five thousand consumers, but it exempts only four specific provisions. Encryption, MFA, access controls and disposal still apply. Confirm your own position with counsel, not a blog post.</p>



<h3 class="wp-block-heading">Should I build this or buy a client portal product?</h3>



<p class="wp-block-paragraph">Buy, if the product genuinely does what you need. A firm with no engineering capacity is safer on a maintained product than a bespoke stack nobody patches. Build when you need controls the product will not give you: per-client key isolation, your own retention logic, or integration with a pipeline you already run. The honest test is whether someone will still own the system in a year.</p>



<h3 class="wp-block-heading">Can I self-host a secure client document vault instead of using AWS?</h3>



<p class="wp-block-paragraph">Yes. Nextcloud or similar on a well-run VPS from a provider such as InterServer or Contabo can meet the same requirements, and it is often simpler to reason about. What you take on is patching, backups, TLS renewal and log retention. The regulation does not care where the data sits. It cares that the controls exist and that you can show they held.</p>



<h3 class="wp-block-heading">How long should client documents be retained?</h3>



<p class="wp-block-paragraph">There is no single number, which is why a schedule beats a default. Tax and professional standards drive minimums for filed returns and engagement documentation, and those vary by jurisdiction and engagement type. The Safeguards Rule pushes the other way. Write both directions down per document class, then configure to match.</p>



<h3 class="wp-block-heading">Is a presigned URL safe to send to a client?</h3>



<p class="wp-block-paragraph">It is safe to use, not safe to send. Treat it as a bearer token with a short life, minted after the client authenticates and consumed immediately. What travels over email should be a notification, not the credential.</p>



<h3 class="wp-block-heading">Does encrypting the bucket mean I avoid breach notification?</h3>



<p class="wp-block-paragraph">Only if the key stayed out of the attacker&#8217;s hands. The Rule treats encrypted customer information as unencrypted where the key was accessed by an unauthorized person. That is the whole argument for keeping key permissions narrower than storage permissions.</p>



<h3 class="wp-block-heading">What is the minimum viable version for a two-person practice?</h3>



<p class="wp-block-paragraph">One bucket with per-client prefixes, a customer managed key, an explicit deny on non-TLS transport, MFA on every human account, short mint-on-click links, S3 data events enabled, and a written retention schedule. That is a weekend of work and it closes five of the six failure families above.</p>



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



<p class="wp-block-paragraph">A secure client document vault is not defined by the storage you picked. It is defined by how narrow the path is between an attacker and a readable file, and by your ability to prove afterwards how many people walked it.</p>



<p class="wp-block-paragraph">Most firms get the storage right and lose at the edges: a link that outlives the engagement, a key policy as wide as the bucket policy, a retention setting that cannot be undone, an audit trail nobody turned on. Fix those four and the rest is ordinary engineering.</p>



<h2 class="wp-block-heading">Where I can help</h2>



<p class="wp-block-paragraph">I work with small firms and the MSPs that support them on exactly this kind of build. Things I take on regularly:</p>



<ul class="wp-block-list">
<li>Designing and building the vault itself: bucket layout, per-client prefix isolation, KMS key policy, and the portal glue that mints short-lived links.</li>



<li>Reviewing an existing setup against the Safeguards Rule&#8217;s technical elements and producing a gap list your Qualified Individual can work from.</li>



<li>Untangling retention: turning a vague policy into a per-document-class schedule, implemented with lifecycle rules, Object Lock and legal holds that do not conflict.</li>



<li>Standing up the evidence layer: S3 data events, a separate logging account, dashboards, and the small set of alerts worth waking up for.</li>



<li>Closing the email intake path without making life harder for clients, including mobile upload flows and a sweep of the historic mailbox backlog.</li>



<li>Offboarding and access reviews: break-glass roles, session revocation, key policy hygiene, and a repeatable quarterly check.</li>
</ul>



<p class="wp-block-paragraph">If you want a second opinion, send the thing itself: a bucket policy, an IAM policy, a CloudTrail event selector, or a description of how documents reach your clients today. It is much easier to say something useful about a real configuration than about a general question.</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-security/secure-client-document-vault-accounting-firm/">Building a Secure Client Document Vault Your Accounting Firm Can Defend</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>Legal Document Intelligence on AWS: The Five Boundaries That Have to Hold</title>
		<link>https://john-nessime.com/blog/cloud-computing/legal-document-intelligence-aws/</link>
		
		<dc:creator><![CDATA[John Nessime]]></dc:creator>
		<pubDate>Tue, 25 Aug 2026 18:00:00 +0000</pubDate>
				<category><![CDATA[Case Studies]]></category>
		<category><![CDATA[Cloud Computing]]></category>
		<category><![CDATA[Web Security]]></category>
		<category><![CDATA[Amazon Bedrock]]></category>
		<category><![CDATA[Amazon Comprehend]]></category>
		<category><![CDATA[Amazon S3]]></category>
		<category><![CDATA[Amazon Textract]]></category>
		<category><![CDATA[Architecture]]></category>
		<category><![CDATA[Audit Logging]]></category>
		<category><![CDATA[AWS]]></category>
		<category><![CDATA[AWS KMS]]></category>
		<category><![CDATA[AWS Organizations]]></category>
		<category><![CDATA[Bedrock Guardrails]]></category>
		<category><![CDATA[Bedrock Knowledge Bases]]></category>
		<category><![CDATA[Cloud Security]]></category>
		<category><![CDATA[Compliance]]></category>
		<category><![CDATA[Data Residency]]></category>
		<category><![CDATA[Document Processing]]></category>
		<category><![CDATA[Encryption]]></category>
		<category><![CDATA[Human In The Loop]]></category>
		<category><![CDATA[Intelligent Document Processing]]></category>
		<category><![CDATA[Legal Tech]]></category>
		<category><![CDATA[Litigation Hold]]></category>
		<category><![CDATA[Metadata Filtering]]></category>
		<category><![CDATA[Multi-Tenant]]></category>
		<category><![CDATA[OCR]]></category>
		<category><![CDATA[OpenSearch Serverless]]></category>
		<category><![CDATA[PrivateLink]]></category>
		<category><![CDATA[RAG]]></category>
		<category><![CDATA[S3 Object Lock]]></category>
		<category><![CDATA[Step Functions]]></category>
		<category><![CDATA[Tenant Isolation]]></category>
		<category><![CDATA[Vector Database]]></category>
		<guid isPermaLink="false">https://john-nessime.com/blog/?p=272</guid>

					<description><![CDATA[<p>A confident answer with a citation from a matter the reader was walled off from. Nothing crashed, nothing alerted. Building legal document intelligence on AWS with Textract, Bedrock and OpenSearch is the easy half; the hard half is tenant isolation, verified deletion, S3 Object Lock holds, what leaves the account, and an audit trail that names the actual user.</p>
<p>The post <a href="https://john-nessime.com/blog/cloud-computing/legal-document-intelligence-aws/">Legal Document Intelligence on AWS: The Five Boundaries That Have to Hold</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 demo was going well until someone asked where the third citation came from.</p>



<p class="wp-block-paragraph">The answer on screen was good. Fluent, specific, correctly hedged, three sources listed underneath it. Then a partner in the room asked a simple question: which matter is source three from? Nobody could answer it in the room, and when we went and looked, it was from a matter that half the people watching the demo were formally walled off from.</p>



<p class="wp-block-paragraph">Nothing had crashed. No alarm fired. The retrieval layer had done exactly what a similarity search does, which is return the nearest vectors, and the nearest vectors did not care about the ethical wall. That is the thing about building a legal document intelligence platform on AWS: the failures that matter almost never look like failures. They look like a confident answer with a citation attached.</p>



<p class="wp-block-paragraph">This post is about the parts of that build that are hard, organised around the five boundaries that actually have to hold. The extraction pipeline is the easy half. I will cover it, but quickly, because the AWS documentation is good and the failure modes are visible. The other four boundaries fail quietly, and that is where the engineering goes.</p>



<h2 class="wp-block-heading">The shape of the thing</h2>



<p class="wp-block-paragraph">Before the boundaries, the skeleton. A legal document intelligence platform on AWS almost always ends up looking like this, whether you plan it or arrive at it:</p>



<ul class="wp-block-list">
<li>Documents land in Amazon S3, one prefix per matter, versioning on.</li>



<li>An S3 event triggers AWS Lambda, which starts an asynchronous Amazon Textract job.</li>



<li>Textract output lands back in S3 as JSON. Something normalises it into text plus page and bounding-box coordinates.</li>



<li>Amazon Comprehend or a foundation model classifies the document and pulls entities: parties, dates, governing law, clause types.</li>



<li>Chunks get embedded and written to a vector store, usually Amazon OpenSearch Serverless behind Amazon Bedrock Knowledge Bases.</li>



<li>An application layer, typically API Gateway plus Lambda, takes a question, retrieves, and calls a model on Amazon Bedrock.</li>
</ul>



<p class="wp-block-paragraph">AWS Step Functions is worth reaching for once you have more than three stages, because Textract jobs are long-running and Lambda timeouts are not a retry strategy. That is the whole architecture. You can stand it up in a fortnight. Then you spend six months on everything below.</p>



<h2 class="wp-block-heading">Boundary one: isolation, and why metadata filtering is not optional</h2>



<p class="wp-block-paragraph">This is the one from the demo, and it is the one that ends engagements.</p>



<p class="wp-block-paragraph">A vector index has no native concept of a matter, a client, or an ethical wall. If you pool every document into one index and rely on the prompt to keep things separate, you have built a system where a well-phrased question can pull privileged material across a wall. Prompt instructions are not an access control. They are a suggestion that usually works.</p>



<p class="wp-block-paragraph">There are two shapes that do work, and the choice between them is a real trade-off rather than a best practice.</p>



<h3 class="wp-block-heading">Pooled index with server-side filtering</h3>



<p class="wp-block-paragraph">Every chunk carries a <code>matter_id</code> and a <code>client_id</code> as metadata. Every retrieval call attaches a filter. With an S3 data source in Bedrock Knowledge Bases, the metadata comes from a sidecar JSON file that sits next to the document and carries a <code>metadataAttributes</code> object.</p>



<pre class="wp-block-code"><code>{
  "metadataAttributes": {
    "matter_id": "M-4417",
    "client_id": "C-108",
    "doc_type": "engagement_letter",
    "privileged": true
  }
}</code></pre>



<p class="wp-block-paragraph">The retrieval call then narrows the search before the model ever sees a chunk:</p>



<pre class="wp-block-code"><code>"retrievalConfiguration": {
  "vectorSearchConfiguration": {
    "filter": {
      "equals": { "key": "matter_id", "value": "M-4417" }
    }
  }
}</code></pre>



<p class="wp-block-paragraph">Two rules make this safe, and both are the kind of thing that gets skipped under deadline. First, the filter value is derived on the server from the authenticated caller&#8217;s claims, never accepted from the client. If the browser can send you a <code>matter_id</code>, the browser can send you a different one. Second, the filter is applied by code that no feature request can bypass. A helper that builds every retrieval request, and a code review rule that no other path may call the retrieve API directly.</p>



<p class="wp-block-paragraph">The trap here is ordering. You cannot filter on an attribute the index does not have. Add <code>matter_id</code> after ingestion and every chunk already in the index is invisible to that filter, which means it either returns for everyone or for no one, depending on how your filter is written. Design the metadata schema before the first ingestion run, not after the first demo.</p>



<h3 class="wp-block-heading">Separate collection per client</h3>



<p class="wp-block-paragraph">The heavier option. A separate OpenSearch Serverless collection per client, which buys you a separate AWS KMS key per client, separate index settings, and a failure mode where a bug in the filtering logic cannot reach across clients at all.</p>



<p class="wp-block-paragraph">It costs you sprawl. Every collection is a resource to provision, monitor, patch policy on, and eventually delete. Ingestion jobs run per data source per knowledge base, so freshness guarantees fragment. For a firm with twelve institutional clients this is fine. For a platform onboarding a hundred small clients it becomes the main operational burden of the product.</p>



<p class="wp-block-paragraph">My default is pooled with strict server-side filtering, and per-client collections only where the client&#8217;s own contract demands a dedicated encryption key. If you cannot say out loud which of those two you are running, you are running neither properly.</p>



<h2 class="wp-block-heading">Boundary two: extraction, and the confidence score everyone ignores</h2>



<p class="wp-block-paragraph">Textract returns a confidence score between 0 and 100 with each prediction. AWS is explicit in its own best-practice guidance that applications sensitive to detection errors should enforce a minimum threshold and route anything below it for human scrutiny, and that the right threshold depends entirely on how the output gets used.</p>



<p class="wp-block-paragraph">Legal documents sit at the harsh end of that. A misread date on an archival scan is a curiosity. A misread date on a limitation period is a problem with a name on it.</p>



<p class="wp-block-paragraph">Practical notes from this layer:</p>



<ul class="wp-block-list">
<li>Use the asynchronous operations for anything multipage. <code>StartDocumentAnalysis</code> and <code>GetDocumentAnalysis</code> handle long PDFs and TIFFs; the synchronous <code>AnalyzeDocument</code> path is for single pages and will fight you on a 400-page bundle.</li>



<li>Textract&#8217;s Queries feature earns its place on structured instruments. Instead of extracting everything and grepping, you ask the document a direct question and get a scoped answer with its own confidence.</li>



<li>Keep the page number and bounding box for every chunk. When a lawyer asks where a sentence came from, &#8220;page 14, second column&#8221; is an answer. &#8220;It&#8217;s in the corpus somewhere&#8221; is not, and the platform loses credibility the first time you say it.</li>



<li>Amazon Augmented AI wires low-confidence predictions into a human review queue with a private workforce, so the reviewers are your people rather than an anonymous pool. For privileged material that distinction is the entire point.</li>
</ul>



<p class="wp-block-paragraph">What I would skip early: building a custom entity recognition model. Comprehend supports custom entity recognition and it is genuinely useful, but you need labelled data you do not have yet, and a foundation model with a decent prompt gets you far enough to find out which entities the users actually care about. Train the custom model once the requirement has stopped moving.</p>



<h2 class="wp-block-heading">Boundary three: retention, where deletion is a distributed problem</h2>



<p class="wp-block-paragraph">Here is the failure that will not show up in any test suite you write.</p>



<p class="wp-block-paragraph">A client asks for a document to be removed. Someone deletes the S3 object. The ticket closes. The document is still fully searchable, because its chunks are sitting in the vector index and will stay there until the data source is re-synced. Meanwhile the Textract JSON output is in a second bucket, the extracted text may be in a database, and if model invocation logging is on, whole passages of the document are sitting in CloudWatch Logs or another S3 bucket entirely.</p>



<p class="wp-block-paragraph">One delete, at least four places holding a copy. Write the deletion path as a real workflow with an assertion at the end, not as a single API call:</p>



<ol class="wp-block-list">
<li>Delete the source object and, if versioning is on, its versions.</li>



<li>Delete the derived artefacts: Textract JSON, normalised text, any thumbnails or page images.</li>



<li>Trigger an ingestion job so the knowledge base drops the orphaned chunks.</li>



<li>Confirm removal by querying the index for the document identifier and expecting nothing back.</li>



<li>Deal with the logs, which means either a retention policy short enough to make the problem expire or a deliberate decision, written down, that logs are out of scope.</li>
</ol>



<p class="wp-block-paragraph">Step four is the one people leave out, and it is the only step that actually proves anything.</p>



<h3 class="wp-block-heading">The opposite problem: things that must not be deleted</h3>



<p class="wp-block-paragraph">Legal work has the reverse requirement too. When a matter goes into litigation hold, the documents need to survive an administrator with delete permissions and a bad afternoon.</p>



<p class="wp-block-paragraph">S3 Object Lock is the mechanism, and it has two independent controls that people routinely conflate. A retention period protects an object version until a fixed date, in either governance mode, which privileged users can override, or compliance mode, which nobody can override and where the period cannot be shortened. A legal hold has no date at all. It stays until someone with <code>s3:PutObjectLegalHold</code> explicitly removes it. The two can be active at once, and while either is active the object version cannot be deleted or overwritten.</p>



<pre class="wp-block-code"><code># Place an indefinite hold on a specific object version
aws s3api put-object-legal-hold 
  --bucket matters-archive 
  --key M-4417/exhibit-c.pdf 
  --version-id 3sL7f2Qz9pXvB1kR 
  --legal-hold Status=ON</code></pre>



<p class="wp-block-paragraph">Two things bite here. Object Lock requires versioning and turns it on automatically, and once enabled on a bucket you cannot turn it off or suspend versioning again. And compliance mode is genuinely permanent: if you set a seven-year retention when you meant seven days, that is the answer, for everyone, including the account root. Test in governance mode first. The bypass path exists and is deliberately awkward, requiring both the <code>s3:BypassGovernanceRetention</code> permission and an explicit <code>x-amz-bypass-governance-retention:true</code> header on the request, which is exactly the level of friction you want on that operation.</p>



<p class="wp-block-paragraph">Design the hold model before you design the deletion model, because holds win. A deletion request that collides with an active hold is a legal question, not an engineering one, and the platform&#8217;s job is to surface the collision clearly rather than resolve it silently.</p>



<h2 class="wp-block-heading">Boundary four: disclosure, or what leaves the account</h2>



<p class="wp-block-paragraph">This is the boundary the client&#8217;s general counsel will ask about, usually in writing, usually before signature. Three separate controls, and they are not interchangeable.</p>



<h3 class="wp-block-heading">The AI services opt-out policy</h3>



<p class="wp-block-paragraph">AWS Organizations has a governance policy type that opts your accounts out of having content processed by certain AI services stored and used for service improvement. The list includes Amazon Textract and Amazon Comprehend, which are precisely the two doing the reading in this architecture. The policy type has to be enabled at the organisation root before you can attach anything:</p>



<pre class="wp-block-code"><code>aws organizations enable-policy-type 
  --root-id r-example 
  --policy-type AISERVICES_OPT_OUT_POLICY</code></pre>



<p class="wp-block-paragraph">Read the AWS documentation on this one carefully rather than taking my summary as gospel, because there is a caveat in it that matters: the services may still need to store your data operationally even when you have opted out of it being used for improvement. Opting out is not the same as the data never existing outside your account. Say that plainly in the client conversation. It is a much better position than being asked about it later.</p>



<h3 class="wp-block-heading">Bedrock&#8217;s data position, and the log that undoes it</h3>



<p class="wp-block-paragraph">Amazon Bedrock&#8217;s published position is that inputs and outputs are not shared with third-party model providers and are not used to train the base models, and that fine-tuning operates on a private copy. That is a strong starting point for privileged content and it is the main reason Bedrock rather than a direct provider API shows up in these builds.</p>



<p class="wp-block-paragraph">Then there is model invocation logging. It captures full prompt and response payloads to CloudWatch Logs or S3, and you want it on, because without it you cannot reconstruct what the system told someone. But understand what you have just built: a log that contains privileged document text, at a per-region setting, in a destination that probably has looser access controls than the document store it came from. Encrypt it with the same KMS key discipline, restrict it harder than you think you need to, and set a retention period on purpose.</p>



<p class="wp-block-paragraph">Worth separating in your head: CloudTrail records that an API call happened and who made it. Model invocation logging records what was in it. You need both, for different questions, and only one of them contains client confidences.</p>



<h3 class="wp-block-heading">Network path</h3>



<p class="wp-block-paragraph">Interface VPC endpoints via AWS PrivateLink keep traffic to Bedrock, Textract and the rest on the AWS network rather than out through an internet gateway or NAT. Add a gateway endpoint for S3 while you are there.</p>



<p class="wp-block-paragraph">Endpoint policies are the part that gets forgotten. An endpoint without a policy is a private path to the whole service, including into accounts that are not yours. Scope it to the operations and resources you actually use, and pair it with an S3 bucket policy that rejects requests not arriving through your endpoint. The first protects what leaves; the second protects what can be reached.</p>



<h3 class="wp-block-heading">The parts that are not on the architecture diagram</h3>



<p class="wp-block-paragraph">Two of these have caught people out badly, and neither is an AWS control.</p>



<p class="wp-block-paragraph">The first is the development environment. You do not want real client documents on a laptop or in a scratch account, so build the parsing and chunking code against synthetic documents on a small separate box. A cheap VPS from a provider like Contabo or InterServer is fine for this, and the separation is worth more than the convenience of iterating in the production account.</p>



<p class="wp-block-paragraph">The second is the reviewers. If your human review loop involves contractors working remotely, their network path and their disks are part of your boundary whether or not you drew them. A managed VPN such as NordVPN or Surfshark handles the network side, and when a matter closes and local copies have to go, a dedicated erasure tool from something like O&amp;O Software does what dragging a folder to the bin does not. Unglamorous, and it is the layer that gets audited.</p>



<h2 class="wp-block-heading">Boundary five: evidence, because someone will ask</h2>



<p class="wp-block-paragraph">At some point the question stops being &#8220;does it work&#8221; and becomes &#8220;who saw what, and when&#8221;. If you cannot answer that from stored records, the platform is not defensible regardless of how good the answers are.</p>



<ul class="wp-block-list">
<li>Turn on CloudTrail data events for the document buckets. They are off by default, they are billed separately from management events, and they are the only way to see individual object-level reads.</li>



<li>Log the retrieval, not just the generation. Store which chunks came back and which filter was applied. When somebody asks whether the wall held on a specific query, this record is the answer and there is no reconstructing it later.</li>



<li>Carry the end user&#8217;s identity through to the audit record. A Lambda execution role in the logs tells you the platform did something. It does not tell you who asked.</li>



<li>Alarm on the filter, not just on errors. A retrieval that ran without a matter filter should page someone, even though it returned HTTP 200 and a perfectly good answer.</li>
</ul>



<p class="wp-block-paragraph">That last one is the closest thing to a single takeaway in this post. The dangerous events in this architecture are successful ones.</p>



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



<h2 class="wp-block-heading">Troubleshooting the things that will go wrong</h2>



<h3 class="wp-block-heading">Retrieval returns nothing after you add a filter</h3>



<p class="wp-block-paragraph">Nearly always a metadata problem rather than a query problem. Either the sidecar metadata file was missing at ingestion, so the chunks carry no attribute to match, or the attribute was added after the chunks were indexed. Check whether the attribute exists on a known chunk before you touch the query. If it does not, you need a re-sync, not a different filter expression.</p>



<h3 class="wp-block-heading">Textract returns text but the layout is scrambled</h3>



<p class="wp-block-paragraph">Common on two-column pleadings and on documents with headers and footers on every page. Raw text detection reads in a reading order that is not always yours. Use the layout and table features rather than plain detection, and hold onto the geometry so you can reassemble columns yourself if the default order is wrong for that document class.</p>



<h3 class="wp-block-heading">The model cites a document that does not exist</h3>



<p class="wp-block-paragraph">Usually the citation is being generated rather than passed through. If document names are being written by the model instead of read from the retrieval response, it will invent plausible ones. Build citations from the retrieval result metadata in your application code and never from the generated text. Bedrock Guardrails helps with contextual grounding, but the structural fix is not asking the model to produce the reference in the first place.</p>



<h3 class="wp-block-heading">Ingestion is slow and nobody knows where</h3>



<p class="wp-block-paragraph">Instrument per stage before you optimise anything. In most of these pipelines the time is in Textract for large scanned bundles, and the fix is parallelism at the document level rather than tuning the extraction itself. Step Functions with a distributed map over documents gets you there without a queue you have to babysit.</p>



<h3 class="wp-block-heading">Costs climb faster than volume</h3>



<p class="wp-block-paragraph">Look at re-processing first. A pipeline that re-extracts a document every time anything downstream changes will quietly multiply your Textract bill against a static corpus. Key the extraction cache on the object&#8217;s content hash, not its path, and make re-ingestion an explicit action. The billing mechanisms differ per service, so read the current pricing pages rather than trusting a number from a blog post.</p>



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



<ul class="wp-block-list">
<li><strong>Treating the system prompt as an access control.</strong> It is a formatting instruction that happens to look like a rule.</li>



<li><strong>Taking the tenant identifier from the request body.</strong> If the client can name the matter, the client can name someone else&#8217;s.</li>



<li><strong>Designing the metadata schema after the first ingestion run.</strong> Retrofitting a filterable attribute means re-indexing the whole corpus.</li>



<li><strong>Assuming a deleted S3 object is gone from the platform.</strong> The chunks, the extraction output and the invocation logs all outlive it.</li>



<li><strong>Setting compliance-mode retention without testing in governance mode.</strong> There is no support ticket that fixes a seven-year mistake.</li>



<li><strong>Enabling model invocation logging without treating the log as privileged.</strong> You have just made a second copy of the documents in a less protected place.</li>



<li><strong>Building VPC endpoints and leaving the default policy on them.</strong> A private path with no policy is still a path.</li>
</ul>



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



<ul class="wp-block-list">
<li>One retrieval helper, server-side filter injection, and a review rule that no other code path calls the retrieve API directly.</li>



<li>Metadata schema agreed and frozen before the first production ingestion. Include the fields you might filter on later, even if unused today.</li>



<li>Page-level provenance on every chunk, surfaced in the interface. It builds trust faster than any accuracy improvement.</li>



<li>A confidence threshold chosen per document class, with a human review path for everything under it.</li>



<li>Deletion implemented as a verified workflow across every store, ending in an assertion that the content is actually unretrievable.</li>



<li>Object Lock legal holds for matters under litigation hold, tested in governance mode before anything runs in compliance mode.</li>



<li>Customer-managed KMS keys across S3, the vector store and the logs, so key access is a control you can actually revoke.</li>



<li>An alarm on unfiltered retrievals, because the failure mode is a success response.</li>
</ul>



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



<h3 class="wp-block-heading">Is a legal document intelligence platform on AWS safe for privileged material?</h3>



<p class="wp-block-paragraph">The services support it. Bedrock&#8217;s stated position is that inputs and outputs are not shared with model providers or used to train base models, PrivateLink keeps traffic off the public internet, and KMS covers encryption at rest. What determines safety is your own configuration: tenant isolation, log handling and deletion behaviour. The platform is exactly as confidential as its weakest copy of the text.</p>



<h3 class="wp-block-heading">Should I use Bedrock Knowledge Bases or build retrieval myself?</h3>



<p class="wp-block-paragraph">Start with Knowledge Bases. It handles chunking, embedding, sync and metadata filtering, and those are weeks of work with no differentiation in them. Build your own when you need retrieval behaviour it does not expose, such as unusual re-ranking or per-tenant chunking strategies. Going custom on day one usually means reimplementing the managed service badly.</p>



<h3 class="wp-block-heading">How do I stop one client&#8217;s documents surfacing in another client&#8217;s answers?</h3>



<p class="wp-block-paragraph">Tag every chunk with a tenant identifier at ingestion, and inject the matching filter server-side on every retrieval from the authenticated caller&#8217;s identity. Never accept the identifier from the client. If the contract requires per-client encryption keys, use a separate collection per client instead, accepting the extra operational load.</p>



<h3 class="wp-block-heading">Does deleting a document from S3 remove it from the search index?</h3>



<p class="wp-block-paragraph">No. The chunks stay in the vector store until an ingestion job runs and reconciles the data source. Until then the document is deleted and still fully searchable, which is the worst combination available. Always finish a deletion by querying the index and confirming nothing comes back.</p>



<h3 class="wp-block-heading">What is the difference between an S3 legal hold and a retention period?</h3>



<p class="wp-block-paragraph">A retention period runs until a fixed date and comes in governance mode, which privileged users can override, or compliance mode, which nobody can. A legal hold has no end date and stays until someone explicitly removes it. They are independent, they can both be active on the same object version, and while either is active the object cannot be deleted or overwritten.</p>



<h3 class="wp-block-heading">Do I need human review, or is the model accurate enough?</h3>



<p class="wp-block-paragraph">Accuracy is the wrong frame. The question is what happens to a wrong answer downstream. Where output feeds a decision with consequences, you want a confidence threshold and a review queue, and Amazon Augmented AI with a private workforce gives you that without building the review tooling yourself. Where output is a search aid a person will verify anyway, review is friction you do not need.</p>



<h3 class="wp-block-heading">Can I run this in a single AWS account?</h3>



<p class="wp-block-paragraph">Technically yes, and for a pilot it is reasonable. It gets uncomfortable once you have production documents and a development environment in the same place, because an IAM mistake has nowhere to stop. Separate accounts under Organizations also gives you the policy layer, including the AI services opt-out policy, which only exists at the organisation level.</p>



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



<p class="wp-block-paragraph">A legal document intelligence platform on AWS is not hard to build. Textract reads, Bedrock reasons, OpenSearch retrieves, and the tutorial version works on the first afternoon.</p>



<p class="wp-block-paragraph">What is hard is that its failures are silent and well-formed. The cross-matter citation, the deleted document that still answers questions, the privileged passage sitting in a log bucket, the retrieval that ran without a filter and returned a beautiful paragraph. None of these throw an error. All of them are the kind of thing that ends a client relationship.</p>



<p class="wp-block-paragraph">So build the boundaries first and the features second, and make sure every one of them is something you can prove from a stored record rather than something you believe about the code. If you can only take one habit from this: alarm on the successful requests that should not have been possible.</p>



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



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



<p class="wp-block-paragraph">I work on AWS document processing and retrieval systems where the confidentiality requirements are real. Typical things I get called in for:</p>



<ul class="wp-block-list">
<li>Reviewing tenant isolation in an existing RAG setup and finding the paths where the filter can be bypassed.</li>



<li>Designing the metadata and chunking schema before ingestion, so filtering works without a re-index later.</li>



<li>Building Textract pipelines with confidence thresholds, human review routing and page-level provenance.</li>



<li>Implementing verified deletion across S3, derived artefacts, the vector index and logs, with a proof step at the end.</li>



<li>Setting up retention and legal hold with S3 Object Lock, including the governance-mode rehearsal before compliance mode.</li>



<li>Locking down the network and logging path: VPC endpoints with real policies, KMS key separation, and audit records that name the actual user.</li>
</ul>



<p class="wp-block-paragraph">If you have an architecture diagram, a Step Functions definition or a retrieval request you are unsure about, send it over and I will tell you what I would change and why.</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/legal-document-intelligence-aws/">Legal Document Intelligence on AWS: The Five Boundaries That Have to Hold</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>The AWS Account Health Check I Run Before Quoting a Project</title>
		<link>https://john-nessime.com/blog/cloud-computing/aws-account-health-check/</link>
		
		<dc:creator><![CDATA[John Nessime]]></dc:creator>
		<pubDate>Mon, 24 Aug 2026 09:00:00 +0000</pubDate>
				<category><![CDATA[Cloud Computing]]></category>
		<category><![CDATA[Cloud Security]]></category>
		<category><![CDATA[FinOps]]></category>
		<category><![CDATA[AWS]]></category>
		<category><![CDATA[AWS Regions]]></category>
		<category><![CDATA[CloudTrail]]></category>
		<category><![CDATA[Confused Deputy]]></category>
		<category><![CDATA[Cost Explorer]]></category>
		<category><![CDATA[Credential Report]]></category>
		<category><![CDATA[Cross-Account Access]]></category>
		<category><![CDATA[Drift Detection]]></category>
		<category><![CDATA[External ID]]></category>
		<category><![CDATA[Freelancing]]></category>
		<category><![CDATA[GuardDuty]]></category>
		<category><![CDATA[IAM]]></category>
		<category><![CDATA[IAM Access Analyzer]]></category>
		<category><![CDATA[Least Privilege]]></category>
		<category><![CDATA[Project Scoping]]></category>
		<category><![CDATA[Root Access Management]]></category>
		<category><![CDATA[Terraform]]></category>
		<category><![CDATA[Trusted Advisor]]></category>
		<guid isPermaLink="false">https://john-nessime.com/blog/?p=552</guid>

					<description><![CDATA[<p>Before I quote AWS work, I run a read-only account health check: six passes covering identity, logging, region sprawl, infrastructure code, cost shape and support escalation. Here are the commands, the pass tests, and the findings that change the number.</p>
<p>The post <a href="https://john-nessime.com/blog/cloud-computing/aws-account-health-check/">The AWS Account Health Check I Run Before Quoting a Project</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">&#8220;It&#8217;s a small AWS account, nothing complicated, we just need someone to tidy it up.&#8221; That sentence has preceded more scope disasters than any other I know of in freelance cloud work, and it&#8217;s almost never a lie. The person saying it believes it, because the parts of an AWS account that make work expensive are the parts nobody looks at.</p>



<p class="wp-block-paragraph">The expensive stuff is not the running instances. It&#8217;s the access keys nobody can attribute, the CloudTrail trail that quietly stopped delivering months ago, the Terraform state that no longer matches reality, and the region somebody spun a proof of concept up in two years ago. None of that comes up on a kickoff call. All of it comes up in week three, after you&#8217;ve quoted.</p>



<p class="wp-block-paragraph">So I run an AWS account health check before I give anyone a number. It&#8217;s read-only, it takes a couple of hours, and it&#8217;s the cheapest insurance in consulting. This post is the actual check: the six passes, the commands, what each finding does to the estimate, and what to do when the client won&#8217;t give you the access to run it.</p>



<h2 class="wp-block-heading">Why the AWS account health check comes before the quote</h2>



<p class="wp-block-paragraph">A quote is a bet on how much unknown there is. You&#8217;re pricing the work in the brief plus everything you&#8217;ll find while doing it, and the second half is invisible until you look. Two accounts can look identical and differ by a factor of three in effort. The difference is nearly always archaeology: how much has to be reverse engineered before you can safely change anything.</p>



<p class="wp-block-paragraph">There&#8217;s a second reason that matters more than pricing. If you touch an account that already had a compromised key sitting in it, the incident becomes yours by proximity. Establishing what the account looked like before you arrived isn&#8217;t paranoia. It&#8217;s the only version of events you&#8217;ll be able to evidence later.</p>



<h2 class="wp-block-heading">The access I ask for, and what a refusal tells me</h2>



<p class="wp-block-paragraph">I ask for a cross-account IAM role that the client creates and my account assumes. Not an IAM user. Not keys in an email. A role, with an external ID the client generates.</p>



<p class="wp-block-paragraph">The external ID solves one specific problem. Without it, a trust policy that names my account can be used by anyone who talks me into assuming a role on their behalf, and I end up acting inside a third party&#8217;s account without knowing it. AWS calls this the confused deputy problem. It&#8217;s the piece of cross-account setup people skip most often.</p>



<p class="wp-block-paragraph">On that role I ask for two AWS managed policies: <code>SecurityAudit</code> and <code>ViewOnlyAccess</code>. The pairing is the point. <code>ViewOnlyAccess</code> lets you enumerate resources across services, so you can see what exists. <code>SecurityAudit</code> lets you read configuration and policy documents, so you can see how it&#8217;s wired together. Neither alone gives you the picture, and neither lets you change anything. Billing is separate again: Cost Explorer read access has to be added explicitly and the feature has to be enabled in the account.</p>



<p class="wp-block-paragraph">The reply to that request tells you as much as the check does.</p>



<ul class="wp-block-list">
<li>&#8220;Here&#8217;s the role ARN and external ID&#8221; means somebody there understands AWS identity. Good sign.</li>

<li>&#8220;I&#8217;ll just make you an admin user, easier&#8221; means nobody is managing access. Expect that looseness everywhere else.</li>

<li>&#8220;Our previous developer set it up and we can&#8217;t reach him&#8221; means you&#8217;re inheriting credentials nobody can account for. Price the rotation work as its own line.</li>

<li>&#8220;We&#8217;d rather not grant access before signing&#8221; is fair. Offer a paid discovery engagement. If they won&#8217;t pay for two hours of assessment, they won&#8217;t pay for the fix.</li>
</ul>



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



<h2 class="wp-block-heading">The six passes</h2>



<p class="wp-block-paragraph">Each pass answers one question. Everything here is read-only, and the order matters, because each pass changes how you read the next.</p>



<h3 class="wp-block-heading">Pass 1: Who can do what</h3>



<p class="wp-block-paragraph">Confirm where you actually are first. That sounds trivial until you&#8217;re in an organization with a dozen similarly named accounts.</p>



<pre class="wp-block-code"><code>aws sts get-caller-identity
aws iam list-account-aliases
aws organizations describe-organization</code></pre>



<p class="wp-block-paragraph">If the third command fails because the account isn&#8217;t in an organization, that&#8217;s a finding by itself. Standalone means no service control policies, no organization trail, and no central place to revoke anything.</p>



<p class="wp-block-paragraph">Then the credential surface. The summary gives counts; the credential report gives per-principal detail, including when each password and key was last used.</p>



<pre class="wp-block-code"><code>aws iam get-account-summary

aws iam generate-credential-report
aws iam get-credential-report --query Content --output text | base64 --decode &gt; credential-report.csv</code></pre>



<p class="wp-block-paragraph">The report generates asynchronously, so the second call may need a retry the first time. What I read from it: how many IAM users exist at all, how many have keys that have never been used, and how many have keys last used a long time ago that are still active.</p>



<p class="wp-block-paragraph">An unused key is worse than a busy one. A key in daily use is attributable to something. A key last used eighteen months ago and still enabled belongs to a person or script nobody remembers, which means nobody will notice when someone else starts using it.</p>



<p class="wp-block-paragraph">Then check whether anyone has ever asked this question before you:</p>



<pre class="wp-block-code"><code>aws accessanalyzer list-analyzers</code></pre>



<p class="wp-block-paragraph">IAM Access Analyzer offers external access analyzers, which flag resources shared outside a zone of trust you define, and unused access analyzers, which surface unused roles, keys and passwords. Unused access analysis is billed per IAM role and user analyzed per month, so it isn&#8217;t free, but it answers the least privilege question far faster than reading policy documents by hand. An empty list means nobody has ever asked this account who can reach what.</p>



<p class="wp-block-paragraph">Root closes the pass. In an AWS Organizations setup, centralized root access management lets the management account or a delegated IAM administrator remove root credentials from member accounts entirely, so there&#8217;s no root password, key or MFA device to manage per account. Live root credentials on every member account is a real finding, and one of the few security fixes that&#8217;s genuinely quick.</p>



<p class="wp-block-paragraph"><em>Pass test: you can name every principal that can reach production, and say when each last did.</em></p>



<h3 class="wp-block-heading">Pass 2: Would anyone notice</h3>



<p class="wp-block-paragraph">This is the pass that most often changes my number, because it&#8217;s the failure that stays invisible until it isn&#8217;t. Logging that exists but has silently stopped working is more dangerous than no logging, since everybody believes they&#8217;re covered.</p>



<pre class="wp-block-code"><code>aws cloudtrail describe-trails 
  --query 'trailList[].{Name:Name,MultiRegion:IsMultiRegionTrail,Org:IsOrganizationTrail}'

aws cloudtrail get-trail-status --name TRAIL_NAME_HERE

aws configservice describe-configuration-recorder-status</code></pre>



<p class="wp-block-paragraph">The first command tells you a trail is configured. The second tells you whether it&#8217;s delivering, which is the part that matters. A trail whose last delivery failed because someone edited the destination bucket policy still shows up in the console as a configured trail. The status call is where the truth is.</p>



<p class="wp-block-paragraph">Multi-region matters for the same reason region sprawl does: a single-region trail records nothing about the region where somebody is quietly running compute. Check GuardDuty too, and how long logs are actually retained. Ninety days of retention against a one-year obligation is a project, not a task, and it belongs in the quote rather than in week four.</p>



<p class="wp-block-paragraph">One naming trap worth knowing: AWS renamed the original Security Hub to Security Hub CSPM and launched a separate, newer service under the Security Hub name. When a client says &#8220;we have Security Hub,&#8221; ask which one, because the findings format and the configuration are not the same thing.</p>



<p class="wp-block-paragraph"><em>Pass test: a trail is delivering right now, covers every region, and its logs are kept for at least as long as the business needs them.</em></p>



<h3 class="wp-block-heading">Pass 3: How wide the blast radius is</h3>



<p class="wp-block-paragraph">People forget regions. Not occasionally, constantly. A demo in Singapore, a test bucket in Ireland, a Lambda deployed from a tutorial that defaulted to us-east-1.</p>



<pre class="wp-block-code"><code>for r in $(aws ec2 describe-regions --query 'Regions[].RegionName' --output text); do
  count=$(aws ec2 describe-instances --region "$r" 
    --query 'length(Reservations[].Instances[])' --output text)
  echo "$r $count"
done</code></pre>



<p class="wp-block-paragraph">Left without <code>--all-regions</code>, <code>describe-regions</code> returns the regions enabled for the account, which is exactly what you want. Adding the flag pulls in disabled regions too, and the per-region calls against those just error out.</p>



<p class="wp-block-paragraph">Run the same loop shape for anything carrying state or cost: RDS instances, load balancers, EBS volumes, NAT gateways. NAT gateways are the classic. They bill hourly whether or not anything routes through them, and a forgotten one in a region nobody visits is pure waste. Then the two exposure checks I never skip:</p>



<pre class="wp-block-code"><code>aws ec2 describe-security-groups 
  --filters Name=ip-permission.cidr,Values=0.0.0.0/0 
  --query 'SecurityGroups[].{Id:GroupId,Name:GroupName,VpcId:VpcId}'

aws s3api list-buckets --query 'Buckets[].Name' --output text</code></pre>



<p class="wp-block-paragraph">For each bucket that matters, check its public access block configuration and its policy. An open security group on 443 in front of a load balancer is fine. An open security group on a database port is a different conversation, and you want that conversation before you own the account.</p>



<p class="wp-block-paragraph"><em>Pass test: you have a list of every region with resources in it, and nothing on that list surprises the client.</em></p>



<h3 class="wp-block-heading">Pass 4: Whether any of this is described in code</h3>



<p class="wp-block-paragraph">The question isn&#8217;t &#8220;do they use Terraform.&#8221; It&#8217;s whether the code still describes the account. Those diverge quietly. Somebody makes an urgent console change during an incident, never brings it back into code, and from then on the repo is fiction that plans cleanly right up until the day it destroys something.</p>



<pre class="wp-block-code"><code>terraform plan -refresh-only

terraform plan -detailed-exitcode</code></pre>



<p class="wp-block-paragraph">The refresh-only plan shows where real infrastructure has drifted from state without proposing to change anything. The detailed exit code version is the one for scripts: it exits 0 for no changes, 1 for an error and 2 when there&#8217;s a diff, so you can gate on drift instead of reading output by eye.</p>



<p class="wp-block-paragraph">Three things I want to know here:</p>



<ul class="wp-block-list">
<li>What fraction of live resources are managed by code? Half a codebase is often worse than none, because it creates false confidence about which half.</li>

<li>Where does state live and who can write to it? Remote state in a versioned, locked S3 bucket is a different risk profile from a state file on a laptop.</li>

<li>Does the pipeline that applies changes still run? Credentials that expired six months ago mean everything since went in by hand.</li>
</ul>



<p class="wp-block-paragraph">Tagging belongs here too. With nothing tagged you can&#8217;t attribute cost, can&#8217;t scope a change safely, and can&#8217;t tell a production resource from an abandoned experiment. Untagged environments add hours to everything that follows.</p>



<p class="wp-block-paragraph"><em>Pass test: a refresh-only plan comes back clean, or the drift is a short list you can read out loud.</em></p>



<h3 class="wp-block-heading">Pass 5: What shape the spend is in</h3>



<p class="wp-block-paragraph">I don&#8217;t care much about the total. I care about the shape: which services dominate, whether the curve is flat or climbing, and whether anything jumped without an explanation.</p>



<pre class="wp-block-code"><code>aws ce get-cost-and-usage 
  --time-period Start=$(date -d '6 months ago' +%Y-%m-01),End=$(date +%Y-%m-01) 
  --granularity MONTHLY 
  --metrics UnblendedCost 
  --group-by Type=DIMENSION,Key=SERVICE</code></pre>



<p class="wp-block-paragraph">The <code>date -d</code> syntax is GNU coreutils; on macOS use the BSD equivalent or a fixed start date. Worth knowing before you loop this: the Cost Explorer API bills per paginated request, unlike the console view, so a wide query across several dimensions is a small but real charge on the client&#8217;s bill. Keep it narrow and mention it in your notes.</p>



<ul class="wp-block-list">
<li>Data transfer in the top three usually means an architecture problem rather than a rate problem. Cross-AZ chatter and NAT gateway egress are the usual suspects.</li>

<li>A step change with no matching launch deserves a direct question. Sometimes it&#8217;s a feature. Sometimes it&#8217;s a runaway job. Occasionally it&#8217;s mining on a stolen key.</li>

<li>No budgets and no anomaly monitors means nobody finds out about a spike until the invoice arrives.</li>

<li>Heavy commitment purchases against workloads you&#8217;re about to re-architect are a constraint on your design, and need to be on the table before you propose anything.</li>
</ul>



<p class="wp-block-paragraph">If the client wants ongoing cost visibility past what Cost Explorer offers, platforms like Vantage or CloudZero sit on the Cost and Usage Report and give per-team allocation and unit economics that native tooling makes awkward. Raise it as a separate line, not part of the engineering work.</p>



<p class="wp-block-paragraph"><em>Pass test: you can explain the top three cost lines in a sentence each, and account for every month-on-month jump.</em></p>



<h3 class="wp-block-heading">Pass 6: What happens at two in the morning</h3>



<p class="wp-block-paragraph">Short pass, about escalation. What support plan is the account on, and would anything wake a human up?</p>



<p class="wp-block-paragraph">Support plan matters more than people expect. On Basic you get the service limits checks and a small subset of Trusted Advisor security and fault tolerance checks, manual refresh only, and no technical support cases at all. The full check library and the Trusted Advisor API need a paid plan. If your engagement depends on opening a case during a cutover, that&#8217;s a prerequisite, not a nice to have.</p>



<p class="wp-block-paragraph">There&#8217;s one date worth carrying in your head, because it&#8217;s a real deprecation rather than a version number. AWS has announced that Developer Support, Business Support and Enterprise On-Ramp are discontinued on 1 January 2027, with Business Support+ as the replacement tier. If you&#8217;re scoping anything that runs past that boundary, check which plan the client is on and whether their support cost is about to move.</p>



<p class="wp-block-paragraph">Then monitoring. CloudWatch alarms wired to an SNS topic with no confirmed subscribers are the same failure as the dead trail: configured, visible, useless. If they run anything beyond alarms, whether that&#8217;s Grafana Cloud, Datadog or a self-hosted Prometheus stack, find out who holds the credentials and whether the routing has been tested this year.</p>



<p class="wp-block-paragraph"><em>Pass test: you can name the human who gets woken up, and the path that reaches them.</em></p>



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



<h2 class="wp-block-heading">The findings that actually move the number</h2>



<ul class="wp-block-list">
<li><strong>Unattributable credentials.</strong> Each one has to be rotated or removed, and each needs a conversation with whoever might break. That&#8217;s coordination time, which is the hardest kind to compress.</li>

<li><strong>No working audit trail.</strong> No baseline means you can&#8217;t prove what you did or didn&#8217;t change. Add the cost of standing logging up before the real work starts.</li>

<li><strong>Significant drift between code and reality.</strong> Reconciliation is slow, fiddly and carries destruction risk. The single largest multiplier I see.</li>

<li><strong>No tags.</strong> A tax on every subsequent task rather than a task of its own, since every scoping decision becomes an investigation.</li>

<li><strong>Resources in unmentioned regions.</strong> Each is a small parallel environment with its own security groups, its own logging gap and its own bill.</li>

<li><strong>One shared admin identity.</strong> Nothing in CloudTrail is attributable to a person, so your first task is identity work the client didn&#8217;t ask for.</li>
</ul>



<p class="wp-block-paragraph">I don&#8217;t present these as criticism. I present them as line items with an hour estimate against each, and let the client decide what&#8217;s in scope. That conversation is much easier before a contract exists than after.</p>



<h2 class="wp-block-heading">If you only run five checks</h2>



<p class="wp-block-paragraph">Sometimes you get twenty minutes and a screen share.</p>



<ol class="wp-block-list">
<li>Pull the credential report and count active keys that haven&#8217;t been used recently.</li>

<li>Confirm a CloudTrail trail exists, is multi-region, and is currently delivering.</li>

<li>Loop the enabled regions for running instances and NAT gateways.</li>

<li>Run a refresh-only plan against whatever infrastructure code exists.</li>

<li>Pull six months of cost grouped by service and look at the shape.</li>
</ol>



<p class="wp-block-paragraph">Those cover identity, evidence, blast radius, delivery speed and money. The rest of the check refines a picture these five already gave you.</p>



<h2 class="wp-block-heading">Where this check goes wrong</h2>



<ul class="wp-block-list">
<li><strong>Treating it as an audit.</strong> You&#8217;re scoping work, not issuing a compliance report. A twenty page findings document makes a prospect defensive and costs you a day.</li>

<li><strong>Checking one region.</strong> The default region in your profile is not where the problems are.</li>

<li><strong>Trusting the console over the API.</strong> Configured and working are different states, and the console is better at showing the first.</li>

<li><strong>Taking long-lived keys because it&#8217;s faster.</strong> Accept an admin access key by email to save fifteen minutes and you&#8217;ve become part of the problem you were hired to assess.</li>

<li><strong>Running the loops unannounced.</strong> A burst of API calls from an unfamiliar principal is exactly what GuardDuty exists to flag. Tell them you&#8217;re doing it and roughly when.</li>
</ul>



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



<h3 class="wp-block-heading">How long should an AWS account health check take?</h3>



<p class="wp-block-paragraph">Two to three hours for a single account once you have access, including the write-up. Multi-account organizations take longer but not linearly, because the interesting variation concentrates in one or two accounts and the rest are copies of a landing zone pattern.</p>



<h3 class="wp-block-heading">Should I charge for the assessment?</h3>



<p class="wp-block-paragraph">For a short engagement, folding it into the proposal is fine. For anything substantial, or where the client is shopping several consultants, quote it as a small standalone piece with a written deliverable. It qualifies the client, protects your time, and the findings have value to them even if they hire someone else.</p>



<h3 class="wp-block-heading">What if the client won&#8217;t give me read-only access before signing?</h3>



<p class="wp-block-paragraph">Quote time and materials, or quote fixed price with an explicit assumptions list naming every unknown. Do not quote a fixed price on an account you haven&#8217;t seen. The pattern that works is a small paid discovery phase, then a fixed price set once you know what you&#8217;re dealing with.</p>



<h3 class="wp-block-heading">Is ReadOnlyAccess enough on its own?</h3>



<p class="wp-block-paragraph">It&#8217;s broader than you probably want. <code>ReadOnlyAccess</code> extends to reading data in some services, not just configuration, which is more than you need for scoping and more than many clients will be comfortable granting. The <code>SecurityAudit</code> and <code>ViewOnlyAccess</code> pairing is narrower and easier to justify in writing.</p>



<h3 class="wp-block-heading">Can I automate the whole thing?</h3>



<p class="wp-block-paragraph">The enumeration, yes, and it&#8217;s worth scripting since you&#8217;ll run it repeatedly. The interpretation, no. Whether five unused IAM users are a problem depends entirely on what the client is trying to do, and that judgement is most of what you&#8217;re selling.</p>



<h3 class="wp-block-heading">What if I find something actively compromised?</h3>



<p class="wp-block-paragraph">Stop and tell them immediately, in writing, with timestamps. Don&#8217;t remediate unilaterally: you&#8217;re read-only, and containment steps can destroy evidence. Your job is a clear description of what you saw and when, so they can involve whoever they need to.</p>



<h3 class="wp-block-heading">Does this apply to small accounts too?</h3>



<p class="wp-block-paragraph">Especially to small accounts. Larger organizations usually have some governance, even imperfect. A three year old account at a small business that four different contractors have touched is where the surprises live, and it&#8217;s exactly the account that gets described as simple.</p>



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



<p class="wp-block-paragraph">An AWS account health check isn&#8217;t a security exercise dressed up as sales. It&#8217;s the only reliable way to price uncertainty, and uncertainty is what you&#8217;re actually quoting on.</p>



<p class="wp-block-paragraph">If you take one habit from this, take the second pass. Everything else on the list is something you&#8217;d eventually trip over while doing the work. A logging gap is different, because it stays invisible while it costs you, and by the time it matters you&#8217;re the person who was in the account when it happened. Check the trail is delivering. Then check the rest.</p>



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



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



<p class="wp-block-paragraph">I do this for consultancies scoping engagements and for businesses who inherited an account they don&#8217;t fully understand. Usually that looks like:</p>



<ul class="wp-block-list">
<li>A read-only AWS account health check with a written findings list, hour estimates per item, and a clear split between urgent and later.</li>

<li>Cross-account audit role setup, with a trust policy and external ID you can hand to any consultant without granting more than you meant to.</li>

<li>Credential archaeology: working out what every active access key belongs to, then rotating or removing the ones that belong to nothing.</li>

<li>Getting CloudTrail, AWS Config and log retention into a state where they&#8217;re delivering rather than just configured.</li>

<li>Terraform drift reconciliation, including importing unmanaged resources back under code without a destructive plan.</li>

<li>Cost shape reviews: the forgotten NAT gateways, orphaned volumes and idle regions, plus budgets and anomaly alerts that reach a real inbox.</li>
</ul>



<p class="wp-block-paragraph">If you&#8217;ve got a credential report, a <code>terraform plan</code> output or a Cost Explorer screenshot you can&#8217;t make sense of, send it over and I&#8217;ll tell you what I&#8217;d look at next.</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-account-health-check/">The AWS Account Health Check I Run Before Quoting a Project</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>Building a Salesforce AI Assistant on Amazon Bedrock Without Leaking Your CRM</title>
		<link>https://john-nessime.com/blog/devops/salesforce-ai-assistant-amazon-bedrock/</link>
		
		<dc:creator><![CDATA[John Nessime]]></dc:creator>
		<pubDate>Tue, 18 Aug 2026 18:00:00 +0000</pubDate>
				<category><![CDATA[Cloud Computing]]></category>
		<category><![CDATA[DevOps]]></category>
		<category><![CDATA[Technical Guides]]></category>
		<category><![CDATA[AgentCore]]></category>
		<category><![CDATA[Agentforce]]></category>
		<category><![CDATA[AI Agents]]></category>
		<category><![CDATA[Amazon Bedrock]]></category>
		<category><![CDATA[API Gateway]]></category>
		<category><![CDATA[Architecture]]></category>
		<category><![CDATA[AWS]]></category>
		<category><![CDATA[Bedrock Guardrails]]></category>
		<category><![CDATA[Cloud Security]]></category>
		<category><![CDATA[Cognito]]></category>
		<category><![CDATA[Data Integration]]></category>
		<category><![CDATA[External Client Apps]]></category>
		<category><![CDATA[Generative AI]]></category>
		<category><![CDATA[Identity Propagation]]></category>
		<category><![CDATA[MCP]]></category>
		<category><![CDATA[OAuth]]></category>
		<category><![CDATA[Rate Limiting]]></category>
		<category><![CDATA[REST API]]></category>
		<category><![CDATA[Salesforce]]></category>
		<guid isPermaLink="false">https://john-nessime.com/blog/?p=211</guid>

					<description><![CDATA[<p>The demo works, then someone sees records they shouldn't. Nothing errors. Here's how to build a Salesforce AI assistant with Amazon Bedrock that respects your sharing model: which doors AWS and Salesforce just closed, why identity propagation is the failure mode that bites, and how to shape the tool surface so the agent can't wander.</p>
<p>The post <a href="https://john-nessime.com/blog/devops/salesforce-ai-assistant-amazon-bedrock/">Building a Salesforce AI Assistant on Amazon Bedrock Without Leaking Your CRM</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 demo went fine. Someone from the support team asked the assistant which accounts looked at risk this quarter, and it answered. Good answer, too. Right opportunities, right open cases, sensible summary.</p>



<p class="wp-block-paragraph">Then somebody noticed that the person who asked couldn&#8217;t see half those opportunities in the Salesforce UI. Different role, different branch of the hierarchy. They saw them through the assistant.</p>



<p class="wp-block-paragraph">Nothing errored. No 403, no exception in the logs, no alert. The integration user had access, so the API call succeeded, so the model got the records, so it summarised them. Every layer did exactly what it was configured to do. That is the failure mode that should worry you most when you build a Salesforce AI assistant with Amazon Bedrock: it is silent, it is correct-looking, and you will not find it in a load test.</p>



<p class="wp-block-paragraph">This post covers what actually goes wrong when you wire a large language model into a CRM: which door AWS closed on the entry path, whose permissions your agent is really running under, how to shape the tool surface so the model can&#8217;t wander, and what happens to your org&#8217;s API budget when an agent starts looping. There&#8217;s a build walkthrough, but the build is the easy part.</p>



<h2 class="wp-block-heading">Two doors closed while you were reading the old tutorial</h2>



<p class="wp-block-paragraph">Most of what ranks for this topic was written against an architecture you can no longer build in a fresh account. Both vendors changed the front door within a few months of each other, and the tutorials haven&#8217;t caught up. Check this before you follow anything, including this post.</p>



<h3 class="wp-block-heading">On the AWS side: Bedrock Agents is now Bedrock Agents Classic</h3>



<p class="wp-block-paragraph">AWS renamed the original Bedrock Agents service to Bedrock Agents Classic and put it into maintenance mode. It stopped accepting new customers on 30 July 2026. This one is worth stating with the date attached, because the date is the whole point.</p>



<p class="wp-block-paragraph">The mechanics matter more than the branding:</p>



<ul class="wp-block-list">
<li>Only <code>CreateAgent</code> and <code>InvokeInlineAgent</code> are restricted. Every other API stays available to everyone.</li>

<li>Allowlisting is per AWS account, based on whether that account used Bedrock Agents in the previous twelve months. Multi-account estates get a split result: your old sandbox works, your new production account doesn&#8217;t.</li>

<li>A non-allowlisted account calling <code>CreateAgent</code> gets an <code>AccessDeniedException</code> with HTTP 403, not a quota error. If you&#8217;re debugging IAM and seeing 403s, this is a plausible cause that no policy change will fix.</li>

<li>There is no exception process. AWS documents this plainly.</li>

<li>The model catalogue inside Classic is frozen. Bedrock itself keeps getting new models; the Classic orchestration layer does not.</li>
</ul>



<p class="wp-block-paragraph">The replacement is Amazon Bedrock AgentCore. It splits into a managed harness (config-driven, closest to the old experience) and code-defined agents on the AgentCore runtime (bring Strands, LangGraph, CrewAI, or your own loop). For a Salesforce assistant, the harness is usually enough, and you can move to code later without re-plumbing the tools.</p>



<p class="wp-block-paragraph">Existing Classic agents keep running. There&#8217;s no announced end-of-life. But a frozen model catalogue sets a ceiling on the thing, and if your team can&#8217;t recreate the agent in a new account, your disaster recovery story has a hole in it.</p>



<h3 class="wp-block-heading">On the Salesforce side: Connected Apps gave way to External Client Apps</h3>



<p class="wp-block-paragraph">Salesforce blocked creation of new Connected Apps through both UI and API, with package installation as the exception. External Client Apps are the replacement. Existing Connected Apps keep working and keep their OAuth flows, so this is a wall on new creation rather than a sunset.</p>



<p class="wp-block-paragraph">The practical symptom is that the <em>New Connected App</em> button is greyed out with no explanation. Every AWS-to-Salesforce tutorial that opens with &#8220;first, create a Connected App&#8221; stops working right there.</p>



<p class="wp-block-paragraph">One difference matters for agent work specifically: External Client Apps do not support the username-password OAuth flow at all. If your integration design assumed you&#8217;d stash a service account username and password somewhere and call it a day, that design is gone. Which is a good thing, and it forces the question the next section is about.</p>



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



<h2 class="wp-block-heading">Failure family one: whose permissions is the assistant using?</h2>



<p class="wp-block-paragraph">This is the one from the opening, and it&#8217;s the reason most of these projects should slow down before they ship.</p>



<p class="wp-block-paragraph">Salesforce&#8217;s security model is genuinely good. An OAuth token runs in a user context, and object permissions, field-level security, and sharing rules all still apply to API calls. A token with the <code>api</code> scope does not bypass CRUD, FLS, or sharing. People hear that and relax.</p>



<p class="wp-block-paragraph">The catch is the phrase &#8220;a user context.&#8221; Which user?</p>



<p class="wp-block-paragraph">If you authenticate the gateway to Salesforce with client credentials, every request from every person runs as one integration user. Salesforce enforces that user&#8217;s permissions perfectly. It has no idea a different human asked the question. You&#8217;ve built what&#8217;s sometimes called a multiplexor: one licence fronting many people, with all the real access control now living in your application layer instead of the platform&#8217;s.</p>



<p class="wp-block-paragraph">At that point the sharing model is decoration. A prompt that talks the model into calling a query tool with a wider filter returns data the asker was never entitled to, and Salesforce logs it as a legitimate read by a legitimate user. There&#8217;s nothing to alert on, because nothing went wrong.</p>



<h3 class="wp-block-heading">Two-legged versus three-legged, and when each is honest</h3>



<p class="wp-block-paragraph">AgentCore Gateway supports both client credentials (two-legged) and authorization code (three-legged) flows for reaching downstream targets, and handles ingress and egress authorisation separately. That distinction is the control you need.</p>



<ul class="wp-block-list">
<li><strong>Two-legged is honest when the data is genuinely org-wide.</strong> Product catalogues, published knowledge articles, case deflection content. Nobody&#8217;s entitlement varies, so a single service identity represents the truth.</li>

<li><strong>Three-legged is the only correct answer for record data.</strong> Opportunities, accounts, contacts, custom objects with sharing rules on them. The token is minted for the actual human, Salesforce applies their sharing, and the answer is bounded by what they could have looked up themselves.</li>
</ul>



<p class="wp-block-paragraph">Three-legged costs you something real, and I&#8217;d rather say so than pretend it&#8217;s free. You need per-user consent, token storage, refresh handling, and a way to deal with a user whose token has expired mid-conversation. AgentCore Identity provides a token vault and handles the consent flow, including binding the authorisation URL to the session that started it so a shared link can&#8217;t be completed by someone else. It&#8217;s still more moving parts than a single secret in Secrets Manager.</p>



<p class="wp-block-paragraph">Pay the cost anyway for record data. The alternative is a system whose blast radius is &#8220;everything the integration user can see,&#8221; discovered by a customer or an auditor rather than by you.</p>



<p class="wp-block-paragraph">If you already run Okta, Auth0, or Ping as your identity provider, Gateway can sit in front of them for inbound auth rather than you standing up a parallel Cognito user pool. Reusing the IdP you already audit is worth more here than saving a config file.</p>



<h2 class="wp-block-heading">Failure family two: the shape of the tool surface</h2>



<p class="wp-block-paragraph">The second mistake is giving the model a tool called <code>run_soql</code> that accepts an arbitrary query string. It demos beautifully. Ask anything, get anything.</p>



<p class="wp-block-paragraph">You&#8217;ve now made the model your query planner and your authorisation layer at once. Every safety property of the system depends on prompt text. Guardrails help with content, not with whether a generated <code>WHERE</code> clause happened to be too generous.</p>



<p class="wp-block-paragraph">Narrow tools are the fix, and they&#8217;re boring on purpose. <code>get_open_cases_for_account(account_id)</code> beats <code>run_soql(query)</code> because the query shape is fixed in your code, the model only supplies an identifier, and you can log and rate-limit each tool independently. When something goes wrong you&#8217;re reading a parameter, not parsing SOQL to work out what the model asked for.</p>



<p class="wp-block-paragraph">AgentCore Gateway takes OpenAPI specs, Smithy models, and Lambda functions as tool input types, and exposes them to the agent as MCP tools. It also has one-click integrations for several SaaS products including Salesforce. Practically that gives you three routes:</p>



<ol class="wp-block-list">
<li><strong>The built-in Salesforce integration</strong> for the fastest path to something working. Good for a proof of concept. Look closely at which operations it exposes before you take it further, because breadth is exactly what you don&#8217;t want.</li>

<li><strong>An OpenAPI target</strong> describing a small purpose-built API in front of Salesforce. This is the one I reach for first. You control the surface completely, and the spec doubles as the tool description the model reads.</li>

<li><strong>Lambda targets</strong> when a tool needs to combine several Salesforce calls, or talk to something else at the same time.</li>
</ol>



<p class="wp-block-paragraph">MCP server targets are also supported, with the gateway aggregating tools from multiple targets into a single <code>tools/list</code> response. If you&#8217;re running your own MCP server as the Salesforce facade, it needs a stable HTTPS endpoint the gateway can reach; a small VPS from a provider like InterServer or Hetzner is plenty for the development and staging copies, with the production one behind whatever your standard ingress is.</p>



<h2 class="wp-block-heading">Failure family three: the write path</h2>



<p class="wp-block-paragraph">Reads leak. Writes destroy. They deserve different treatment and usually get the same treatment, which is how you end up with three hundred opportunities silently restaged.</p>



<p class="wp-block-paragraph">Start read-only and stay there longer than feels necessary. When you do add writes, the constraints that actually help:</p>



<ul class="wp-block-list">
<li><strong>One record per call.</strong> No bulk update tool. If the model wants to change twenty records it makes twenty calls, each individually logged and individually rate-limited. Slow is a feature here.</li>

<li><strong>Separate the write identity.</strong> A distinct External Client App and permission set for writes means you can revoke the write path at 2am without taking the assistant offline.</li>

<li><strong>Human confirmation on anything irreversible.</strong> The AgentCore harness supports inline function tools that pause the agent and hand control back to your client code, equivalent to return-of-control. That pause is where a person says yes.</li>

<li><strong>Stamp the source.</strong> A custom field recording that a change came from the assistant, with the session and actor ID, is the difference between a ten-minute rollback and an afternoon of forensics.</li>
</ul>



<p class="wp-block-paragraph">Salesforce field history tracking is on a limited number of fields per object. If the field your assistant writes to isn&#8217;t tracked, you have no before-value. Check that before go-live, not after.</p>



<h2 class="wp-block-heading">Failure family four: what happens under load</h2>



<p class="wp-block-paragraph">Salesforce meters API requests against a daily org-wide allocation that depends on your edition and licence count. Your assistant does not consume that budget the way a nightly sync does.</p>



<p class="wp-block-paragraph">A single conversational turn can be several tool calls. A model that doesn&#8217;t get a clean answer will often retry with a different filter. Ten support agents having ten conversations each becomes a request pattern nobody modelled, and the org-wide limit means the integration that breaks first might be your ETL job, your marketing platform, or your billing sync. Not the assistant. That&#8217;s what makes it hard to diagnose.</p>



<p class="wp-block-paragraph">Three things that help, in order of how much they buy you:</p>



<ul class="wp-block-list">
<li>Cap tool calls per session in your agent config, and return a clear message when the cap is hit rather than letting the loop run.</li>

<li>Cache anything org-wide and slow-moving. Picklist values, record type metadata, product catalogue. These are a surprising share of calls and almost never change mid-day.</li>

<li>Watch API usage as a leading indicator, not a post-mortem. Salesforce exposes remaining daily requests via the REST limits resource. Scrape it on a schedule into CloudWatch, Grafana Cloud, or whatever you already page from, and alert on the trend rather than the wall.</li>
</ul>



<h2 class="wp-block-heading">Wiring it up</h2>



<p class="wp-block-paragraph">With the design decisions settled, the build is short. The AgentCore CLI scaffolds and deploys the harness.</p>



<ol class="wp-block-list">
<li>Create an External Client App in Salesforce with the narrowest OAuth scopes the tools need. Assign it via permission set, not profile.</li>

<li>Build the tool layer: an OpenAPI spec for your purpose-built endpoints, or Lambda functions for composite operations.</li>

<li>Create an AgentCore Gateway, attach the target, and configure the credential provider in AgentCore Identity for the egress side.</li>

<li>Create the harness, point it at the gateway, and deploy.</li>
</ol>



<p class="wp-block-paragraph">Scaffolding a harness and attaching a gateway looks like this. The <code>--gateway-arn</code> value is the gateway you created in step three, and the <code>--type</code> tells the CLI this tool is a gateway rather than one of the built-in tools such as the browser or code interpreter:</p>



<pre class="wp-block-code"><code>agentcore create --name sf-assistant

agentcore add tool --harness sf-assistant 
  --type agentcore_gateway --name sf-gateway 
  --gateway-arn arn:aws:bedrock-agentcore:REGION:ACCOUNT_ID:gateway/GATEWAY_ID</code></pre>



<p class="wp-block-paragraph">Then declare the model and the system prompt, and deploy. Check the current model identifiers in the Bedrock console rather than copying one from any blog post, this one included, because they change:</p>



<pre class="wp-block-code"><code>agentcore add harness 
  --name sf-assistant 
  --model-id YOUR_MODEL_ID 
  --system-prompt "You answer questions about CRM records using only the provided tools. Never guess a record value." 
  --tools sf-gateway

agentcore deploy</code></pre>



<p class="wp-block-paragraph">Invoking it takes a session ID, and an actor ID if you want memory scoped per user. Skipping <code>--actor-id</code> is the memory equivalent of the integration user problem: one shared memory store, everyone&#8217;s context in it.</p>



<pre class="wp-block-code"><code>agentcore invoke --harness sf-assistant 
  --session-id "$(uuidgen)" --actor-id alice 
  "Which of my open opportunities have had no activity in three weeks?"</code></pre>



<p class="wp-block-paragraph">Note &#8220;my&#8221; in that question. With three-legged auth it means something. With an integration user it means nothing, and the model will cheerfully invent a definition.</p>



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



<p class="wp-block-paragraph"><strong><code>AccessDeniedException</code> when creating an agent.</strong> If you&#8217;re calling <code>CreateAgent</code> or <code>InvokeInlineAgent</code> on the Classic API, check whether the account has prior Bedrock Agents usage before you touch IAM. No policy fixes this one. Build on AgentCore instead.</p>



<p class="wp-block-paragraph"><strong>The New Connected App button is greyed out.</strong> Expected. Create an External Client App. If you have a documented technical requirement that only a Connected App satisfies, that&#8217;s a Salesforce Support request, not a setting you can toggle.</p>



<p class="wp-block-paragraph"><strong>Tools appear in <code>tools/list</code> but calls fail.</strong> Gateway can cache tool schemas from a target without holding a working credential for calls. Discovery succeeding proves the schema synced, not that egress auth works. Look at the credential provider.</p>



<p class="wp-block-paragraph"><strong>The assistant returns fewer records than the user expects.</strong> Under three-legged auth this is often correct behaviour that looks like a bug. Have the user run the same query in the Salesforce UI. If the UI shows more, check FLS on the specific fields; if it shows the same, the assistant is right and the user&#8217;s mental model is wrong.</p>



<p class="wp-block-paragraph"><strong>Answers are confidently wrong about record values.</strong> Usually the model answering from training data instead of calling a tool. Tighten the system prompt to forbid answering record questions without a tool result, and check the traces to confirm which turns actually called out.</p>



<p class="wp-block-paragraph"><strong>Everything works, then breaks at the same time daily.</strong> Look at org-wide API consumption before you look at your own code. Something else in the org may be eating the allocation on a schedule.</p>



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



<ul class="wp-block-list">
<li>Using a System Administrator profile for the integration identity because it makes the errors go away.</li>

<li>Exposing a generic SOQL tool and calling the prompt a security control.</li>

<li>Testing only in a sandbox with one user, where the sharing model is invisible because there&#8217;s nothing to share.</li>

<li>Treating Guardrails as an access control layer. They filter content; they don&#8217;t know who&#8217;s asking.</li>

<li>Shipping writes in the first release because the read-only version felt underwhelming in the demo.</li>

<li>Building on Bedrock Agents Classic in a new account without checking allowlist status first.</li>
</ul>



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



<ul class="wp-block-list">
<li>Use three-legged OAuth for anything governed by sharing rules. Reserve client credentials for genuinely org-wide data.</li>

<li>Give each tool one job and a fixed query shape. The model supplies parameters, never structure.</li>

<li>Separate read and write identities so you can kill one without the other.</li>

<li>Scope memory per actor, not per deployment.</li>

<li>Test with at least three users at different levels of the role hierarchy, and diff the answers. Identical answers to &#8220;show me my pipeline&#8221; is the signal that identity isn&#8217;t propagating.</li>

<li>Keep end-to-end traces and ship them somewhere you already look, so agent behaviour lands in the same place as the rest of your telemetry.</li>
</ul>



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



<h3 class="wp-block-heading">Can I still use Bedrock Agents for a new Salesforce project?</h3>



<p class="wp-block-paragraph">Only if the AWS account already used Bedrock Agents in the previous twelve months. Accounts without that history get an <code>AccessDeniedException</code> on <code>CreateAgent</code>, and AWS has said there&#8217;s no exception process. For anything new, build on AgentCore.</p>



<h3 class="wp-block-heading">Do I need Agentforce to build a Salesforce AI assistant with Amazon Bedrock?</h3>



<p class="wp-block-paragraph">No. Agentforce is Salesforce&#8217;s own agent layer, and it can call out to AWS, which is useful when you want the assistant to live inside the Salesforce UI and inherit the Einstein Trust Layer. But an AgentCore agent calling Salesforce APIs through a gateway is a complete architecture on its own, and it&#8217;s the better fit when the assistant also needs to reach systems outside Salesforce.</p>



<h3 class="wp-block-heading">Does the model see all my CRM data?</h3>



<p class="wp-block-paragraph">It sees whatever your tools return. That&#8217;s determined by the OAuth identity and the tool definitions, not by the model. This is why the identity decision matters more than the model choice.</p>



<h3 class="wp-block-heading">Will Bedrock Guardrails stop the assistant leaking records?</h3>



<p class="wp-block-paragraph">No, and this is worth being blunt about. Guardrails filter content categories and can mask sensitive data patterns. They have no concept of which user is asking or which records that user is entitled to. A record correctly retrieved under an over-permissioned token passes every guardrail check, because there&#8217;s nothing objectionable about it. Guardrails are a good layer. They are not this layer.</p>



<h3 class="wp-block-heading">How do I keep the assistant from burning the org&#8217;s API limit?</h3>



<p class="wp-block-paragraph">Cap tool calls per session, cache org-wide reference data, and monitor remaining daily requests via the REST limits resource on a schedule. The allocation is org-wide, so the symptom of overuse usually shows up in some other integration first.</p>



<h3 class="wp-block-heading">Should I migrate an existing Bedrock Agents Classic setup?</h3>



<p class="wp-block-paragraph">There&#8217;s no deadline and no announced end-of-life, so there&#8217;s no emergency. The reasons to move are the frozen model catalogue and the fact that new accounts can&#8217;t recreate your agent. If you&#8217;re still actively developing the thing, move. If it&#8217;s stable and finished, it can wait. AWS publishes a capability mapping and a CLI import path for existing configurations.</p>



<h3 class="wp-block-heading">Is an External Client App harder to set up than a Connected App?</h3>



<p class="wp-block-paragraph">Slightly different rather than harder. The main structural change is that developer settings and admin policies are separated, so the app definition and the per-org access controls are configured independently. The one real removal is the username-password flow, which no longer exists in the ECA model.</p>



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



<p class="wp-block-paragraph">A Salesforce AI assistant with Amazon Bedrock is not really a model problem. Model quality is the part that will take care of itself, and the part you can change with one config line later.</p>



<p class="wp-block-paragraph">The part that won&#8217;t take care of itself is identity. If the assistant answers as one integration user, you have quietly moved your entire access control model from a platform that enforces it well into a prompt that enforces it not at all, and nothing in your monitoring will tell you. Get that right first. Then argue about models.</p>



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



<h2 class="wp-block-heading">Need help building or auditing this?</h2>



<p class="wp-block-paragraph">Most of the work on these projects is unglamorous: deciding what the agent is allowed to touch, and proving it can&#8217;t touch anything else. That&#8217;s the part I&#8217;m useful for.</p>



<ul class="wp-block-list">
<li>Auditing an existing Salesforce AI integration for identity propagation gaps, including testing across role hierarchy levels to find silent over-permissioning.</li>

<li>Migrating a Bedrock Agents Classic deployment to AgentCore, including mapping action groups to gateway targets.</li>

<li>Designing the tool surface: replacing generic query tools with narrow, logged, individually rate-limited operations.</li>

<li>Setting up three-legged OAuth between AgentCore Gateway and Salesforce, including token vault and consent handling.</li>

<li>Moving integrations from Connected Apps to External Client Apps without breaking existing OAuth flows.</li>

<li>Instrumenting API consumption and agent traces so you see problems before an unrelated integration starts failing.</li>
</ul>



<p class="wp-block-paragraph">If you&#8217;ve got something concrete, send it over: a gateway target config, an agent trace, a tool schema, or just the error you&#8217;re stuck on. Easier to say something useful about a real setup than a hypothetical one.</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/salesforce-ai-assistant-amazon-bedrock/">Building a Salesforce AI Assistant on Amazon Bedrock Without Leaking Your CRM</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>Keep Kubernetes Secrets Out of Git Without Kidding Yourself</title>
		<link>https://john-nessime.com/blog/devops/keep-kubernetes-secrets-out-of-git/</link>
		
		<dc:creator><![CDATA[John Nessime]]></dc:creator>
		<pubDate>Sun, 09 Aug 2026 18:00:00 +0000</pubDate>
				<category><![CDATA[DevOps]]></category>
		<category><![CDATA[Kubernetes]]></category>
		<category><![CDATA[Technical Guides]]></category>
		<category><![CDATA[Web Security]]></category>
		<category><![CDATA[Argo CD]]></category>
		<category><![CDATA[CI/CD]]></category>
		<category><![CDATA[Cloud Security]]></category>
		<category><![CDATA[External Secrets Operator]]></category>
		<category><![CDATA[Flux CD]]></category>
		<category><![CDATA[Git]]></category>
		<category><![CDATA[GitOps]]></category>
		<category><![CDATA[HashiCorp Vault]]></category>
		<category><![CDATA[Infrastructure as Code]]></category>
		<category><![CDATA[Sealed Secrets]]></category>
		<category><![CDATA[Secrets Management]]></category>
		<category><![CDATA[SOPS]]></category>
		<category><![CDATA[Supply Chain Security]]></category>
		<guid isPermaLink="false">https://john-nessime.com/blog/?p=174</guid>

					<description><![CDATA[<p>Encrypting a Secret before you commit it only closes one of the five places that value comes to rest. Here is how to keep Kubernetes secrets out of Git properly: Sealed Secrets, SOPS and External Secrets compared honestly, the key-backup problem nobody plans for, and what to actually do when a credential is already in your history.</p>
<p>The post <a href="https://john-nessime.com/blog/devops/keep-kubernetes-secrets-out-of-git/">Keep Kubernetes Secrets Out of Git Without Kidding Yourself</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 message usually arrives on a quiet afternoon. A secret scanner has flagged your infrastructure repo, and the finding is a Postgres connection string sitting in a <code>values.yaml</code> that someone committed a long time ago and deleted a few weeks later. The file is gone from the working tree. The commit is not.</p>



<p class="wp-block-paragraph">The reflex at that point is to reach for history rewriting. That is the wrong first move, and I will come back to why. But the bigger problem is the one that shows up six months after you clean up: teams migrate to Sealed Secrets or SOPS, tick the box, and quietly assume the whole class of problem is now solved. It is not. Encrypting a value before it enters Git closes exactly one door out of about five.</p>



<p class="wp-block-paragraph">This post covers how to <strong>keep Kubernetes secrets out of Git</strong> in a way that survives contact with a real cluster: the three families of solution and where each one genuinely wins, the disaster-recovery trap that bites hardest, how to stop plaintext reaching the repo in the first place, and what to actually do about credentials already in your history.</p>



<h2 class="wp-block-heading">The failure that actually bites: encrypted in Git, wide open in the cluster</h2>



<p class="wp-block-paragraph">Sealed Secrets and SOPS both terminate in the same place. The controller decrypts, the API server accepts, and what lands in etcd is an ordinary Kubernetes Secret. Ordinary means base64-encoded, which is an encoding, not a cipher. Anyone who can run <code>kubectl get secret -o yaml</code> in that namespace reads the value in one pipe through <code>base64 -d</code>.</p>



<p class="wp-block-paragraph">So the honest description of what these tools buy you is narrower than the marketing suggests. They stop the credential appearing in a public object store with permanent history and a CDN in front of it. They do nothing about the cluster&#8217;s own blast radius.</p>



<p class="wp-block-paragraph">Three things need to be true alongside whichever tool you pick:</p>



<ul class="wp-block-list">
<li><strong>Encryption at rest is on.</strong> A stock Kubernetes control plane writes Secrets to etcd unencrypted unless you configure an <code>EncryptionConfiguration</code>, ideally with a KMS provider. Managed distributions vary. On EKS, envelope encryption is something you enable, not something you inherit. Check yours rather than assuming.</li>

<li><strong>RBAC on secrets is actually restrictive.</strong> A depressing number of clusters grant blanket <code>get</code> and <code>list</code> on secrets to service accounts that only ever needed one. That single verb turns a namespace compromise into a credential dump.</li>

<li><strong>Secrets are mounted as files, not shoved into environment variables where you can help it.</strong> Environment variables leak through <code>/proc/&lt;pid&gt;/environ</code>, through anything that dumps the environment on crash, and through every debug endpoint an application framework has ever shipped. A projected volume with tight permissions is the quieter option.</li>
</ul>



<p class="wp-block-paragraph">Quick sanity check on the RBAC side, which takes about ten seconds and surprises people:</p>



<pre class="wp-block-code"><code># Can the default service account in a namespace read every secret in it?
kubectl auth can-i get secrets 
  --namespace production 
  --as system:serviceaccount:production:default</code></pre>



<p class="wp-block-paragraph">If that returns <code>yes</code>, your Git hygiene is not the weakest link in the chain.</p>



<h2 class="wp-block-heading">Already leaked? Rotate first, rewrite second, and understand what rewriting does not do</h2>



<p class="wp-block-paragraph">Order matters here more than technique. GitHub&#8217;s own guidance is blunt about it: once a credential has been pushed, revoke or rotate it first, because that step alone removes the attacker&#8217;s ability to use it. History surgery is the optional follow-up, not the fix.</p>



<p class="wp-block-paragraph">The reason is that a force-push does not reach everywhere the commit went. Objects survive in forks, which inherit the full object graph at fork time. They survive in colleagues&#8217; clones. On GitHub they remain reachable by SHA through cached views and through pull requests that referenced them, and purging those caches is a support request, not a git command. If the repo was public even briefly, treat the credential as burned and move on.</p>



<p class="wp-block-paragraph">When you do rewrite, use <code>git filter-repo</code>. It has replaced <code>filter-branch</code> as the recommended tool and it is dramatically faster on any repo with real history.</p>



<pre class="wp-block-code"><code># Work on a bare mirror, never your day-to-day clone.
git clone --mirror git@github.com:org/infra.git infra-clean.git
cd infra-clean.git

# Strip one path from every commit that ever touched it.
git filter-repo --path clusters/production/values.yaml --invert-paths

# filter-repo deletes the origin remote on purpose, so you cannot
# force-push a half-finished rewrite by muscle memory. Add it back.
git remote add origin git@github.com:org/infra.git
git push --force --all
git push --force --tags</code></pre>



<p class="wp-block-paragraph">Then the unglamorous part: close or merge open pull requests before you start, because every SHA downstream of the rewrite changes. Tell every collaborator to delete their clone and clone again. A <code>git pull</code> will happily drag the old objects back in.</p>



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



<h2 class="wp-block-heading">Two families of solution, and how to tell which one you are in</h2>



<p class="wp-block-paragraph">Every approach to this problem is either <em>encrypt the value and commit the ciphertext</em>, or <em>commit a pointer and fetch the value at runtime</em>. Everything else is implementation detail. The choice is mostly about whether you already run a secret store, and whether you can tolerate the CLI step in the middle of your workflow.</p>



<h3 class="wp-block-heading">Sealed Secrets: the lowest-friction way to start</h3>



<p class="wp-block-paragraph">A controller in the cluster holds an RSA private key and publishes the matching certificate. The <code>kubeseal</code> CLI encrypts a Secret manifest against that certificate and produces a <code>SealedSecret</code> custom resource. Only that controller can decrypt it, so the resulting YAML is safe to commit.</p>



<pre class="wp-block-code"><code># Fetch the controller's public certificate. This is safe to commit;
# it can only encrypt, never decrypt.
kubeseal --fetch-cert 
  --controller-name=sealed-secrets 
  --controller-namespace=kube-system 
  &gt; pub-cert.pem

# Build a Secret manifest locally. --dry-run=client means it is rendered
# and never sent to the API server.
kubectl create secret generic db-auth 
  --from-literal=password='&lt;value&gt;' 
  --dry-run=client -o yaml &gt; db-auth.yaml

# Encrypt against the cert. Offline, so no cluster access needed here.
kubeseal --cert pub-cert.pem --format yaml &lt; db-auth.yaml &gt; db-auth-sealed.yaml

rm db-auth.yaml</code></pre>



<p class="wp-block-paragraph">Because you can hand out <code>pub-cert.pem</code>, developers can seal secrets without any cluster credentials at all. That is the underrated part, and it is the reason this is the one I reach for first on a small team with no existing vault.</p>



<p class="wp-block-paragraph">Two details that catch people. First, sealing is scoped by default: a <code>SealedSecret</code> is bound to its exact name and namespace, and moving the file to a different namespace makes it undecryptable. That is deliberate, it stops a developer sealing a secret into a namespace they should not have access to, and you can relax it with <code>--scope namespace-wide</code> or <code>--scope cluster-wide</code> if you genuinely need to. Second, the Helm chart names the controller <code>sealed-secrets</code> while the CLI looks for <code>sealed-secrets-controller</code> by default, so <code>--controller-name</code> is not optional in practice.</p>



<p class="wp-block-paragraph">Where it stops being the right answer: multi-cluster. Each cluster generates its own key pair, so a sealed file is not portable, and a fleet means either copying private keys around or maintaining per-cluster ciphertext for the same value. Rotation is also manual. There is no dynamic secret story here at all.</p>



<h3 class="wp-block-heading">SOPS with age: encrypt files, not just Kubernetes objects</h3>



<p class="wp-block-paragraph">SOPS encrypts the values inside a structured file and leaves the keys readable, so a diff still tells you which field changed even if it cannot tell you what it changed to. Paired with <code>age</code>, which uses short X25519 keys instead of the GPG keyring experience, it is pleasant to live with.</p>



<p class="wp-block-paragraph">A <code>.sops.yaml</code> at the repo root drives everything through path matching, which is how you get per-environment key separation without anyone having to remember flags:</p>



<pre class="wp-block-code"><code>creation_rules:
  - path_regex: clusters/production/.*.yaml$
    encrypted_regex: '^(data|stringData)$'
    age: age1...   # production recipient
  - path_regex: clusters/staging/.*.yaml$
    encrypted_regex: '^(data|stringData)$'
    age: age1...   # staging recipient</code></pre>



<p class="wp-block-paragraph">The <code>encrypted_regex</code> line is the one worth understanding rather than copying. Without it SOPS encrypts everything including <code>metadata</code>, and you lose the readable-diff property that made this approach attractive. Restricting it to <code>data</code> and <code>stringData</code> keeps names, namespaces and labels reviewable in a pull request.</p>



<p class="wp-block-paragraph">Flux decrypts SOPS natively: you point a <code>Kustomization</code> at a Secret holding the age private key via <code>decryption.provider: sops</code> and it handles the rest. Argo CD does not have equivalent built-in support, so you bolt it on with a config management plugin such as KSOPS or the Argo CD Vault Plugin. Worth confirming against current Argo CD docs before you commit to it, because plugin mechanics there have changed more than once.</p>



<p class="wp-block-paragraph">SOPS also handles files that are not Kubernetes manifests: Terraform variable files, Ansible vars, plain <code>.env</code>. If you have config living outside the cluster, that reach is a real advantage over Sealed Secrets.</p>



<h3 class="wp-block-heading">External Secrets Operator: stop putting the value in Git at all</h3>



<p class="wp-block-paragraph">The third approach sidesteps encryption entirely. You commit a reference, and a controller resolves it against a real secret store: HashiCorp Vault, AWS Secrets Manager, Azure Key Vault, Google Secret Manager, or one of the newer hosted options like Doppler, Infisical or 1Password. The repo contains a path, never a value.</p>



<pre class="wp-block-code"><code>apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
  name: db-auth
  namespace: production
spec:
  refreshInterval: 1h
  secretStoreRef:
    name: vault-backend
    kind: ClusterSecretStore
  target:
    name: db-auth
  data:
    - secretKey: password
      remoteRef:
        key: production/postgres
        property: password</code></pre>



<p class="wp-block-paragraph">This is the only one of the three that gives you rotation without a commit. Change the value in Vault, wait for <code>refreshInterval</code>, and the cluster Secret updates. It also gives you a real audit trail, because reads happen against a system built to log them.</p>



<p class="wp-block-paragraph">The cost is honest and worth stating. You have introduced a runtime dependency: if the store is unreachable, new workloads cannot get their credentials. You still have a bootstrap credential problem, because the operator needs to authenticate to the store somehow, and that root of trust has to get into the cluster by some other path. And Git is no longer the complete description of your system, which is a genuine philosophical cost if GitOps purity matters to you.</p>



<p class="wp-block-paragraph"><strong>One live upgrade trap.</strong> The <code>external-secrets.io/v1beta1</code> API was removed in ESO 0.17.0. Before that, in the 0.16 series, a conversion webhook served both versions and rewrote stored objects to <code>v1</code>, which produced permanent drift in Argo CD for anyone whose Git manifests still said <code>v1beta1</code>. Argo sees <code>v1</code> in the cluster, Git says <code>v1beta1</code>, and it reconciles forever. Update your manifests to <code>v1</code> before you cross that boundary, not after.</p>



<h3 class="wp-block-heading">Secrets Store CSI Driver: when a Secret object is one object too many</h3>



<p class="wp-block-paragraph">Worth knowing about even if you do not use it. The CSI driver mounts values from an external store directly into the pod as a volume, so no Kubernetes Secret object needs to exist at all. That closes the etcd exposure completely. It costs you the ability to consume the value as an environment variable, and it means the secret is only available to pods that mount it, which is either exactly what you want or a nuisance depending on the workload. Use it where the compliance posture demands that etcd never sees the value.</p>



<h2 class="wp-block-heading">The key you forgot to back up</h2>



<p class="wp-block-paragraph">This is the failure I would put money on, and it is the one nobody rehearses. Every encrypt-into-Git approach concentrates all your risk into one small piece of key material. Lose it and your repository turns into a directory of well-organised noise.</p>



<p class="wp-block-paragraph">Concretely: rebuild a cluster from scratch, install a fresh Sealed Secrets controller, and it generates a new key pair. Every <code>SealedSecret</code> in your repo is now undecryptable. Your GitOps repo, the one that was supposed to let you recreate everything, cannot recreate anything that needed a credential. You find this out during the incident, not before it.</p>



<ul class="wp-block-list">
<li><strong>Back up the sealing key out of band.</strong> The controller&#8217;s private key lives in a Secret in its namespace. Export it, encrypt it, and store it somewhere that is not the cluster and not the repo. A password manager or an offline copy is fine. Test the restore.</li>

<li><strong>Same for age.</strong> The private key is one line of text. Its size makes it feel unimportant. It is the entire thing.</li>

<li><strong>Add a second recipient for anything that matters.</strong> SOPS encrypts the data key once per recipient, so listing both an age key and a KMS key means either path can decrypt. The developer works with age locally, the cluster decrypts through KMS, and no single lost key is fatal.</li>

<li><strong>Know your re-key procedure before you need it.</strong> For SOPS that is <code>sops updatekeys</code> across every file matched by the rule. For Sealed Secrets it means re-sealing everything against the new certificate. Neither is hard. Both are miserable to work out under pressure.</li>
</ul>



<h2 class="wp-block-heading">Stopping plaintext from reaching the repo in the first place</h2>



<p class="wp-block-paragraph">All of the above assumes the encryption step happens. The commit that leaks is always the one where somebody skipped it, usually while debugging at the end of a long day.</p>



<ol class="wp-block-list">
<li><strong>Put a scanner in the pre-commit hook.</strong> <code>gitleaks</code> and <code>trufflehog</code> both work well here. Local hooks are bypassable, which is fine, because their job is catching accidents rather than stopping a determined person.</li>

<li><strong>Run the same scan in CI, on the whole history, not just the diff.</strong> The diff-only scan misses everything that predates the day you added the scanner.</li>

<li><strong>Turn on server-side push protection.</strong> GitHub&#8217;s push protection rejects known credential patterns at push time. GitLab and platforms like GitGuardian offer equivalents. This is the layer that actually holds, because it does not depend on anyone&#8217;s local setup.</li>

<li><strong>Make the plaintext path short-lived by construction.</strong> Never write a plaintext manifest to a tracked path. Use <code>/tmp</code>, or pipe straight into the encrypt step so the value never lands on disk at all.</li>

<li><strong>Add <code>*.dec.yaml</code>, <code>secret.yaml</code> and friends to <code>.gitignore</code> as a convention.</strong> Not a control, just one less way to be careless.</li>

<li><strong>Remember your shell history.</strong> A <code>--from-literal</code> with a real password ends up in <code>~/.bash_history</code> in cleartext. Use <code>--from-file</code>, or read from a variable, or prefix the command with a space if your shell is configured to skip those.</li>
</ol>



<h2 class="wp-block-heading">Troubleshooting the four things that go wrong</h2>



<h3 class="wp-block-heading">The SealedSecret applies cleanly but no Secret appears</h3>



<p class="wp-block-paragraph">Almost always a scope mismatch. The resource was sealed for one name or namespace and applied to another. The API server accepts the custom resource happily; the controller is where the failure surfaces.</p>



<pre class="wp-block-code"><code># The controller logs are the only place this error lives.
kubectl logs -n kube-system deploy/sealed-secrets --tail=50

# Confirm what the CRD actually thinks its name and namespace are.
kubectl get sealedsecret db-auth -n production -o yaml</code></pre>



<p class="wp-block-paragraph">The second common cause: the file was sealed against a certificate from a different cluster, or from before a key rotation. Re-fetch the cert and re-seal.</p>



<h3 class="wp-block-heading">The ExternalSecret sits there doing nothing</h3>



<p class="wp-block-paragraph">The status conditions carry the real message, and the events under <code>describe</code> usually name the provider error directly.</p>



<pre class="wp-block-code"><code>kubectl get externalsecret db-auth -n production
kubectl describe externalsecret db-auth -n production
kubectl describe clustersecretstore vault-backend</code></pre>



<p class="wp-block-paragraph">Check the store before the secret. If the <code>SecretStore</code> is not <code>Ready</code>, nothing referencing it will ever sync, and the error you want is on the store, not the <code>ExternalSecret</code>. When using a <code>ClusterSecretStore</code>, remember that any secret reference inside it needs an explicit <code>namespace</code>, because a cluster-scoped object has no namespace of its own to fall back on.</p>



<h3 class="wp-block-heading">Argo CD or Flux reports permanent drift</h3>



<p class="wp-block-paragraph">Two usual causes. Either an API version conversion is rewriting stored objects to a version your Git manifests do not use, as with the ESO <code>v1beta1</code> removal above, or a controller is adding fields to the generated Secret that your reconciler then tries to remove. Compare the live object against the rendered manifest field by field before you start changing anything.</p>



<h3 class="wp-block-heading">SOPS decryption fails only in the cluster</h3>



<p class="wp-block-paragraph">You encrypted for one recipient and the cluster holds a different key. This is the classic outcome of listing multiple recipients in <code>.sops.yaml</code> but running <code>sops --encrypt</code> with an explicit <code>--age</code> flag that overrides the rule. Decrypt locally to prove your key works, then check which recipients are actually listed in the file&#8217;s SOPS metadata block. The recipient list is stored in the file, in the clear, which makes this a fast thing to diagnose.</p>



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



<ul class="wp-block-list">
<li>Rewriting history before rotating the credential, and treating the rewrite as the remediation.</li>

<li>Assuming a private repo is safe enough. Private repos get forked internally, cloned onto laptops, and made public by accident.</li>

<li>Committing the encrypted file and the plaintext source next to it, because the encrypt step wrote to a new filename and nobody deleted the original.</li>

<li>Never backing up the sealing key or the age identity.</li>

<li>Leaving encryption at rest off, so the credential moves from a Git leak to an etcd snapshot leak.</li>

<li>Encrypting the entire manifest with SOPS instead of just <code>data</code> and <code>stringData</code>, and losing every useful diff.</li>

<li>Using one age key or one sealing controller for all environments, so a staging compromise reaches production ciphertext.</li>

<li>Piping a Helm release&#8217;s rendered output into a debug artefact in CI. Helm renders secrets in the clear, and CI artefacts are frequently world-readable inside an organisation.</li>
</ul>



<h2 class="wp-block-heading">How I would actually decide</h2>



<p class="wp-block-paragraph">Skip the feature matrix and answer three questions in order.</p>



<ol class="wp-block-list">
<li><strong>Do you already run a secret store?</strong> If Vault or a cloud secret manager is already in your environment and someone owns it, use External Secrets Operator. You get rotation and audit for almost no additional conceptual load, and the argument is over.</li>

<li><strong>Do you have config outside Kubernetes?</strong> Terraform variables, Ansible vars, application <code>.env</code> files. If yes, SOPS with age, because one tool covers all of it and the per-path rules give you environment separation for free.</li>

<li><strong>Neither?</strong> Sealed Secrets. One cluster, a handful of secrets, a team that wants this solved this afternoon. It is the shortest distance to a repo with no plaintext in it, and migrating away later is straightforward because the plaintext values are recoverable from a running cluster.</li>
</ol>



<p class="wp-block-paragraph">Whichever you land on, the cluster-side work is the same and it is not optional: encryption at rest, tight RBAC on secrets, files rather than environment variables, and a tested backup of whatever key material the scheme depends on. On a managed control plane from a provider like DigitalOcean, Linode or a hyperscaler, the encryption-at-rest configuration is partly theirs and partly yours, so read the specific documentation rather than trusting a general answer. On a self-managed cluster running on your own VPS instances, for instance a k3s setup on InterServer or similar, all of it is yours and none of it is on by default.</p>



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



<h3 class="wp-block-heading">Is base64 in a Kubernetes Secret a form of encryption?</h3>



<p class="wp-block-paragraph">No. Base64 is an encoding that exists so binary values survive YAML parsing. It is reversed with a single command and provides no confidentiality whatsoever. Anyone reading the manifest reads the secret.</p>



<h3 class="wp-block-heading">Should I use Sealed Secrets or External Secrets Operator?</h3>



<p class="wp-block-paragraph">If you already run Vault, AWS Secrets Manager or an equivalent, use External Secrets Operator: you get rotation without commits and a proper audit trail. If you have no secret store and no appetite to run one, Sealed Secrets is a faster path with fewer moving parts. The trade-off is that Sealed Secrets has no rotation story and its keys are per-cluster.</p>



<h3 class="wp-block-heading">What happens if I lose the Sealed Secrets private key?</h3>



<p class="wp-block-paragraph">Every <code>SealedSecret</code> in your repository becomes permanently undecryptable, and you regenerate every credential from source. This is the single most common way this setup fails in practice. Back the key up somewhere outside the cluster and the repo, and test restoring it.</p>



<h3 class="wp-block-heading">Do I still need to rewrite Git history after rotating a leaked credential?</h3>



<p class="wp-block-paragraph">Often not. Once the credential is revoked it cannot be used, which usually resolves the actual risk. Rewriting is worth the disruption when the value is not rotatable, when it is personal data rather than a credential, or when a compliance process requires it. Weigh it against breaking every open pull request and forcing everyone to re-clone.</p>



<h3 class="wp-block-heading">Can Argo CD decrypt SOPS files on its own?</h3>



<p class="wp-block-paragraph">Not natively, unlike Flux, which has SOPS decryption built into its Kustomize controller. With Argo CD you add a config management plugin such as KSOPS or the Argo CD Vault Plugin. Check the current Argo CD documentation for the supported plugin mechanism before building around it.</p>



<h3 class="wp-block-heading">Is it safe to commit the Sealed Secrets certificate or the age public key?</h3>



<p class="wp-block-paragraph">Yes, and committing them is the point. Both are public halves of an asymmetric pair and can only encrypt. Putting them in the repo is what lets developers seal new secrets without cluster access. The private halves never go anywhere near Git.</p>



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



<p class="wp-block-paragraph">If you take one idea away, make it this: the work to <strong>keep Kubernetes secrets out of Git</strong> and the work to keep them safe in the cluster are two separate jobs, and finishing the first one feels a lot like finishing both. It is not. A repo full of <code>SealedSecret</code> resources sitting in front of an unencrypted etcd with permissive RBAC has moved the exposure, not removed it.</p>



<p class="wp-block-paragraph">Pick the approach that matches what you already run. Back up the key on day one, not after the first rebuild. Turn on encryption at rest. And when something does leak, rotate before you reach for <code>filter-repo</code>, because rotation is the part that actually stops the bleeding.</p>



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



<h2 class="wp-block-heading">Need help sorting out secrets in your cluster?</h2>



<p class="wp-block-paragraph">This is work I do regularly, and most of it is less dramatic than it sounds once someone has done it before. Things I can help with:</p>



<ul class="wp-block-list">
<li>Auditing a GitOps repo for committed credentials, including full history, and producing a prioritised rotation list rather than a wall of scanner output</li>

<li>Setting up Sealed Secrets, SOPS with age, or External Secrets Operator against Vault or a cloud secret manager, including the key backup and restore procedure</li>

<li>Migrating an existing repo off plaintext or off a scheme that no longer fits, without a big-bang cutover</li>

<li>Fixing Argo CD or Flux reconciliation loops caused by secret controllers and API version conversions</li>

<li>Enabling and verifying etcd encryption at rest, and tightening RBAC on secrets across namespaces</li>

<li>Adding pre-commit and CI secret scanning that people will not immediately disable because of false positives</li>
</ul>



<p class="wp-block-paragraph">If you have a controller log, a stuck <code>ExternalSecret</code>, or a scanner report you are not sure how to triage, send it over and I will tell you what I make of it.</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/keep-kubernetes-secrets-out-of-git/">Keep Kubernetes Secrets Out of Git Without Kidding Yourself</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>Podman vs Docker: Is the Switch Actually Worth It?</title>
		<link>https://john-nessime.com/blog/devops/podman-vs-docker-worth-it/</link>
		
		<dc:creator><![CDATA[John Nessime]]></dc:creator>
		<pubDate>Fri, 07 Aug 2026 18:00:00 +0000</pubDate>
				<category><![CDATA[DevOps]]></category>
		<category><![CDATA[Docker]]></category>
		<category><![CDATA[Linux]]></category>
		<category><![CDATA[Architecture]]></category>
		<category><![CDATA[Cloud Security]]></category>
		<category><![CDATA[Container Runtime]]></category>
		<category><![CDATA[Containers]]></category>
		<category><![CDATA[Docker Compose]]></category>
		<category><![CDATA[Infrastructure]]></category>
		<category><![CDATA[Podman]]></category>
		<category><![CDATA[Production]]></category>
		<category><![CDATA[Quadlet]]></category>
		<category><![CDATA[Rootless Containers]]></category>
		<category><![CDATA[Self Hosting]]></category>
		<category><![CDATA[Systemd]]></category>
		<guid isPermaLink="false">https://john-nessime.com/blog/?p=162</guid>

					<description><![CDATA[<p>Podman is the better-designed container engine and Docker is the better-supported one. A working engineer's comparison: the architectural difference that drives everything, where each one wins and loses, the four things that genuinely break in a migration (compose, the Docker socket, privileged ports, restart on boot), and a decision procedure to run against your own setup.</p>
<p>The post <a href="https://john-nessime.com/blog/devops/podman-vs-docker-worth-it/">Podman vs Docker: Is the Switch Actually Worth It?</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 usually arrives from outside the engineering team. Someone doing an access review notices that half the developers are in the <code>docker</code> group on the build hosts, asks what that grants them, and gets an answer they do not like. Or procurement forwards a note about Docker Desktop licensing and wants to know whether there is a free option.</p>



<p class="wp-block-paragraph">Either way you end up looking at Podman, reading a Podman vs Docker comparison table that says &#8220;daemonless&#8221; and &#8220;rootless by default&#8221;, and trying to work out whether those words are worth a migration.</p>



<p class="wp-block-paragraph">Here is the short version, and I will spend the rest of the post defending it. The architectural difference between the two engines is real and it matters more than the feature tables suggest. The migration cost is also real, and it is almost entirely concentrated in four places that comparison posts tend to skip. Whether the switch is worth it depends on which of those four you actually depend on.</p>



<p class="wp-block-paragraph">This post covers the one architectural difference everything else follows from, an honest profile of each engine with where it wins and where it does not, the four things that genuinely break when you switch, and a decision procedure you can run against your own setup rather than against a generic one.</p>



<h2 class="wp-block-heading">The difference everything else follows from</h2>



<p class="wp-block-paragraph">Docker is a client and a daemon. The <code>docker</code> CLI is a thin thing that talks to <code>dockerd</code>, which runs as root and owns every container on the host. Podman has no daemon. When you run <code>podman run</code>, the container is started as a child of your own process and supervised by a small monitor, not handed off to a privileged background service.</p>



<p class="wp-block-paragraph">Two consequences follow, and they are the whole argument.</p>



<p class="wp-block-paragraph"><strong>Membership of the <code>docker</code> group is equivalent to root.</strong> Not &#8220;close to root&#8221;, not &#8220;root if misconfigured&#8221;. The daemon runs as root and will happily create a container that bind-mounts the host&#8217;s root filesystem, so anyone who can talk to the socket can read or write anything on the box. Docker&#8217;s own documentation says as much. This is the finding that shows up in access reviews, and it is not a bug you can configure away without switching to rootless mode.</p>



<p class="wp-block-paragraph"><strong>Process supervision belongs to whoever started the container.</strong> With Docker, the daemon restarts your containers according to the restart policy, and if the daemon wedges you have a bad afternoon regardless of what the containers are doing. With Podman, containers are ordinary processes, so systemd supervises them the same way it supervises everything else on the machine, with the same logging, dependency ordering and failure handling.</p>



<p class="wp-block-paragraph">Podman&#8217;s rootless mode builds on user namespaces: UID 0 inside the container maps to your unprivileged UID on the host. A break-out lands in a normal user account rather than on a root shell. Docker has a rootless mode too and it works, but it is an opt-in you configure rather than the path of least resistance, which in practice means far fewer installations run it.</p>



<h2 class="wp-block-heading">Docker: where it wins and where it doesn&#8217;t</h2>



<h3 class="wp-block-heading">Where it wins</h3>



<ul class="wp-block-list">
<li><strong>The ecosystem assumes it.</strong> Testcontainers, VS Code Dev Containers, Traefik&#8217;s container discovery, Portainer, most CI runners, half the SDKs on your machine: all of them expect a Docker socket. Some work fine against Podman&#8217;s compatible socket. Some do not, and finding out which is your problem.</li>

<li><strong>Compose is first-party.</strong> Docker Compose is maintained by the same people who maintain the engine, tracks the Compose specification closely, and gets features like <code>--wait</code> and profiles as soon as they exist. Nothing in the Podman world matches that.</li>

<li><strong>Every answer to every question already exists.</strong> Fifteen years of Stack Overflow. That is not a technical merit but it is a real operational one at 2am.</li>

<li><strong>Cross-platform experience is smoother.</strong> Docker Desktop on macOS and Windows is a polished product, and the VM it manages is largely invisible.</li>
</ul>



<h3 class="wp-block-heading">Where it doesn&#8217;t</h3>



<ul class="wp-block-list">
<li><strong>The root daemon is a standing liability.</strong> One privileged process that everything flows through, plus a group that is root by another name.</li>

<li><strong>Docker Desktop is licensed, and the terms are specific.</strong> Docker&#8217;s own license page states Desktop is free for personal use, education, non-commercial open source, and small businesses defined as fewer than 250 employees <em>and</em> less than ten million dollars in annual revenue. Cross either threshold and commercial use needs a paid subscription. Government entities need one regardless. Docker Engine and the Moby project are not affected. This catches people out because the software installs without asking for a key.</li>

<li><strong>systemd integration is bolted on.</strong> You can wrap a Compose stack in a unit file, but systemd is supervising a wrapper, not the containers.</li>

<li><strong>Restarting the daemon touches everything.</strong> An engine upgrade is a whole-host event rather than a per-service one.</li>
</ul>



<h2 class="wp-block-heading">Podman: where it wins and where it doesn&#8217;t</h2>



<h3 class="wp-block-heading">Where it wins</h3>



<ul class="wp-block-list">
<li><strong>Rootless is the default, not a project.</strong> You get the safer posture by doing nothing special, which is the only kind of security control that survives contact with a busy team.</li>

<li><strong>Quadlet is genuinely better than restart policies.</strong> You write a declarative unit file, systemd generates a real service from it, and container lifecycle becomes indistinguishable from any other service on the box. This is the feature I would actually miss if I went back.</li>

<li><strong>No licensing question to answer.</strong> Podman is Apache 2.0 with no commercial-use restriction, and on Red Hat Enterprise Linux, Fedora, Rocky and AlmaLinux it is the packaged, supported option already sitting in the repos.</li>

<li><strong>Pods and Kubernetes YAML are first-class.</strong> <code>podman kube play</code> runs a Kubernetes manifest locally, and <code>podman generate kube</code> goes the other way. If your destination is Kubernetes, the mental model transfers.</li>

<li><strong>Per-user isolation comes free.</strong> Two developers on a shared build host cannot see or stop each other&#8217;s containers, because they are running in separate user namespaces rather than as siblings under one daemon.</li>
</ul>



<h3 class="wp-block-heading">Where it doesn&#8217;t</h3>



<ul class="wp-block-list">
<li><strong>Compose is a compatibility story, not a native one.</strong> More on this below, because it is the single biggest source of migration pain.</li>

<li><strong>Rootless networking has sharp edges.</strong> Traffic goes through a userspace network stack, and the behaviour is not identical to a root bridge. Source IPs, host-loopback access and privileged ports all behave differently, and you will meet at least one of those.</li>

<li><strong>Swarm and Docker plugins do not exist.</strong> Podman does not implement Swarm mode, and Docker volume and network plugins are not supported. If you use either, the conversation is over.</li>

<li><strong>Desktop platforms mean a VM you can see.</strong> Podman on macOS and Windows runs a Podman machine, and the seams show more than Docker Desktop&#8217;s do.</li>

<li><strong>Documentation assumes Docker.</strong> Every vendor quickstart, every README, every &#8220;run this to try it&#8221; snippet is written for <code>docker run</code>. Most translate directly. The ones that do not cost you an hour each.</li>
</ul>



<h2 class="wp-block-heading">The four things that actually break</h2>



<p class="wp-block-paragraph">This is the part worth reading twice. Command compatibility is not the problem; <code>podman</code> accepts almost every <code>docker</code> flag you know. The problems are elsewhere.</p>



<h3 class="wp-block-heading">1. Compose</h3>



<p class="wp-block-paragraph">There are two routes and they are not equivalent.</p>



<p class="wp-block-paragraph"><strong>podman-compose</strong> is a separate Python implementation. It covers the common cases and diverges on the uncommon ones. Container naming is the one that bites scripts first: it has historically used underscores where Docker Compose v2 uses hyphens, so anything referencing <code>project-web-1</code> by name stops finding it.</p>



<p class="wp-block-paragraph"><strong>The Podman socket plus real Docker Compose</strong> is the route I would take. Podman exposes a Docker-compatible REST API over a systemd socket, and the actual Compose binary talks to it:</p>



<pre class="wp-block-code"><code># Start the Docker-compatible API socket for your user.
systemctl --user enable --now podman.socket

# Confirm it exists before pointing anything at it.
ls -l /run/user/$(id -u)/podman/podman.sock

# Point the real Docker Compose at Podman instead of dockerd.
export DOCKER_HOST=unix://$XDG_RUNTIME_DIR/podman/podman.sock
docker compose up -d</code></pre>



<p class="wp-block-paragraph">You keep the tool that tracks the Compose specification and swap only the engine underneath. The catch is that the API is a compatibility layer, not the Docker daemon, so behaviour can differ at the edges. Run your real compose file through it before you commit to anything.</p>



<h3 class="wp-block-heading">2. Anything that mounts the Docker socket</h3>



<p class="wp-block-paragraph">Traefik discovering containers by label, Portainer, Testcontainers in your test suite, any CI job that does Docker-in-Docker. All of these want <code>/var/run/docker.sock</code>. The Podman socket serves the same API and many of them work against it unchanged, but &#8220;many&#8221; is doing work in that sentence. Inventory what mounts the socket <em>before</em> you plan the migration, not after.</p>



<h3 class="wp-block-heading">3. Ports below 1024</h3>



<p class="wp-block-paragraph">A rootless container cannot bind a privileged port, because the process genuinely is not privileged. If your compose file publishes 80 and 443, it will fail, and the error will not obviously say why.</p>



<p class="wp-block-paragraph">Three ways out, in the order I would try them:</p>



<ol class="wp-block-list">
<li><strong>Publish a high port and put a host reverse proxy in front.</strong> Nginx or Caddy running as a normal system service, listening on 443, proxying to 8443. No kernel tuning, and you probably wanted TLS termination on the host anyway.</li>

<li><strong>Lower the privileged port threshold.</strong> The kernel exposes <code>net.ipv4.ip_unprivileged_port_start</code>, and setting it to 0 lets any user bind any port. It works, and it is a host-wide change that weakens a defence for every process on the machine, not just yours. Understand that before you write it into a playbook.</li>

<li><strong>Run that one container rootful.</strong> Podman supports it. Mixing modes is a legitimate choice as long as it is deliberate and documented rather than something that happened.</li>
</ol>



<h3 class="wp-block-heading">4. Restart on boot</h3>



<p class="wp-block-paragraph">This is the one that catches people who tested on a laptop and shipped to a server. Rootless containers belong to a user session, and a user&#8217;s services are stopped when their last session ends. Reboot the box and nothing comes back, no matter what restart policy you set.</p>



<p class="wp-block-paragraph">The fix is two parts. Enable lingering so the user&#8217;s systemd instance runs without a login, and define the container as a Quadlet unit rather than relying on a restart policy:</p>



<pre class="wp-block-code"><code># Let this user's services run without an active login session.
sudo loginctl enable-linger appuser

# Verify. Linger=yes is the line that matters.
loginctl show-user appuser --property=Linger</code></pre>



<p class="wp-block-paragraph">Then the unit itself. Quadlet files look like systemd units with a Podman-specific section, and a generator turns them into real services at boot and on <code>daemon-reload</code>. Rootless units live in <code>~/.config/containers/systemd/</code>; system ones in <code>/etc/containers/systemd/</code>.</p>



<pre class="wp-block-code"><code># ~/.config/containers/systemd/api.container

[Unit]
Description=Application API
After=network-online.target

[Container]
# Fully qualified. Podman does not assume Docker Hub.
Image=registry.example.com/api:1.4.2
PublishPort=8080:8080
Volume=api-data.volume:/var/lib/api
Environment=NODE_ENV=production

[Service]
Restart=always

[Install]
# Starts at boot once lingering is enabled for this user.
WantedBy=default.target</code></pre>



<pre class="wp-block-code"><code># Regenerate services from Quadlet files, then start it.
systemctl --user daemon-reload
systemctl --user start api

# From here it is an ordinary service.
systemctl --user status api
journalctl --user -u api -f</code></pre>



<p class="wp-block-paragraph">Note that <code>podman generate systemd</code>, which you will find in older tutorials, is deprecated in favour of Quadlet. Podman&#8217;s documentation says it receives urgent bug fixes only and no new features. Do not build anything new on it.</p>



<p class="wp-block-paragraph">One smaller trap while you are here: Podman does not assume Docker Hub for unqualified image names. <code>nginx:alpine</code> may prompt you to choose a registry or fail outright depending on configuration. Write <code>docker.io/library/nginx:alpine</code>, or whatever your registry is, everywhere. This is arguably the correct behaviour and it is still an afternoon of edits.</p>



<h2 class="wp-block-heading">Podman vs Docker: how I would decide</h2>



<p class="wp-block-paragraph">Run these in order and stop at the first one that answers.</p>



<ol class="wp-block-list">
<li><strong>Do you use Swarm, or Docker volume or network plugins?</strong> Stay on Docker. Podman does not implement them and no workaround changes that.</li>

<li><strong>Is a compliance requirement or an access review driving this?</strong> Switch, or at minimum move to rootless. &#8220;The docker group is root&#8221; is a finding you cannot argue with, and rootless Podman is the shortest path to closing it.</li>

<li><strong>Are you on RHEL, Fedora, Rocky or AlmaLinux?</strong> Lean Podman. It is the packaged, supported, tested-by-the-vendor option on those distributions, and you are swimming with the current instead of against it.</li>

<li><strong>Is Docker Desktop licensing a real cost for your organisation?</strong> Check the thresholds against your actual headcount and revenue before assuming either way. If you are over, this is a budget conversation with a free technical answer, which is a rare shape of problem.</li>

<li><strong>Does your test suite or CI depend on the Docker socket?</strong> Prototype that specific dependency against the Podman socket first. It is the highest-risk item and the cheapest to test. If it fails, everything else is academic.</li>

<li><strong>None of the above, and Docker is working?</strong> Stay. &#8220;Podman has nicer architecture&#8221; is true and it is not a business case. Migrations cost weeks, introduce risk, and the containers you are already running do not care which engine started them.</li>
</ol>



<p class="wp-block-paragraph">The honest summary: Podman is the better-designed engine and Docker is the better-supported one. Design wins when you are building something new or when a policy requirement forces the question. Support wins when you have a working system and a limited number of weeks.</p>



<h2 class="wp-block-heading">If you do switch, do it in this order</h2>



<ol class="wp-block-list">
<li><strong>Inventory the socket mounts.</strong> Grep your compose files and CI config for <code>docker.sock</code>. That list is your actual migration scope.</li>

<li><strong>Stand up one non-critical service on Podman.</strong> A staging box, an internal tool, something on a spare VPS. Run it for a fortnight through at least one reboot before you draw conclusions.</li>

<li><strong>Fully qualify every image reference.</strong> Do this in the compose files while they still run under Docker. It changes nothing there and removes a whole class of failure later.</li>

<li><strong>Switch the engine, keep Compose.</strong> Podman socket plus <code>DOCKER_HOST</code>. Change one variable at a time so you know what broke.</li>

<li><strong>Convert to Quadlet last, and only where it earns its place.</strong> Long-lived services benefit. A three-container stack you rebuild constantly is fine staying on Compose.</li>
</ol>



<h2 class="wp-block-heading">Arguments that don&#8217;t survive contact</h2>



<ul class="wp-block-list">
<li><strong>&#8220;Podman is a drop-in replacement.&#8221;</strong> The CLI nearly is. The surrounding tooling is not, and that is where the weeks go.</li>

<li><strong>&#8220;Daemonless means faster.&#8221;</strong> The engine is not your bottleneck. Rootless networking has historically been slower than a root bridge, not faster. Pick Podman for the security model, not for benchmark numbers.</li>

<li><strong>&#8220;Rootless means secure.&#8221;</strong> It means a break-out lands somewhere less catastrophic. A container running rootless with the host filesystem bind-mounted read-write is still a bad idea.</li>

<li><strong>&#8220;Docker is dying.&#8221;</strong> It is not. Docker Engine, Compose and Buildx are actively developed and hosted CI runners still ship them by default.</li>

<li><strong>&#8220;Just alias docker to podman and you&#8217;re done.&#8221;</strong> The alias handles the commands. It does not handle the socket, the ports, the boot behaviour or the image names.</li>
</ul>



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



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



<h3 class="wp-block-heading">Can Podman run docker-compose files?</h3>



<p class="wp-block-paragraph">Yes, by two routes. Install <code>podman-compose</code>, which is a separate implementation with good but incomplete coverage, or enable the Podman socket and point real Docker Compose at it with <code>DOCKER_HOST</code>. The second gives better specification coverage because you are running the actual Compose tool. Neither is a guarantee; test your own file.</p>



<h3 class="wp-block-heading">Is Podman actually more secure than Docker?</h3>



<p class="wp-block-paragraph">Its default posture is better. Rootless out of the box means no root daemon and no group that is root by another name. Docker can be configured to a similar posture with rootless mode, so the difference is less &#8220;Podman is secure and Docker is not&#8221; and more &#8220;Podman&#8217;s safe path is the easy path&#8221;. Defaults determine what most installations actually run.</p>



<h3 class="wp-block-heading">Do I need to pay for Docker?</h3>



<p class="wp-block-paragraph">Docker Engine on Linux is open source and free. Docker Desktop is the licensed product. Per Docker&#8217;s terms it is free for personal use, education, non-commercial open source, and businesses with fewer than 250 employees and under ten million dollars in annual revenue; cross either threshold, or be a government entity, and commercial use requires a subscription. If you only run Docker Engine on Linux servers, the licensing question does not apply to you.</p>



<h3 class="wp-block-heading">Why do my Podman containers disappear after a reboot?</h3>



<p class="wp-block-paragraph">Because rootless containers run under your user&#8217;s systemd instance, which is torn down when your last session ends. Run <code>loginctl enable-linger</code> for the service account and define the containers as Quadlet units with <code>WantedBy=default.target</code>. A Podman restart policy alone will not survive a reboot.</p>



<h3 class="wp-block-heading">What is Quadlet and do I have to use it?</h3>



<p class="wp-block-paragraph">Quadlet is a systemd generator that turns declarative container definitions into real systemd services. You do not have to use it, but the alternative is hand-writing unit files, and the older <code>podman generate systemd</code> command is deprecated in favour of it. For anything long-lived on a server, Quadlet is the current answer.</p>



<h3 class="wp-block-heading">Can Podman and Docker coexist on the same host?</h3>



<p class="wp-block-paragraph">Yes. They keep separate image and container stores, so nothing collides at the storage layer. Watch for port conflicts if both are running services, and be deliberate about which one a given script is talking to, because <code>DOCKER_HOST</code> and an alias can quietly disagree with each other.</p>



<h3 class="wp-block-heading">Does Podman work with Kubernetes?</h3>



<p class="wp-block-paragraph">Better than Docker does, in the sense that it speaks the format natively. <code>podman kube play</code> runs a Kubernetes manifest on a single host and <code>podman generate kube</code> produces one from running containers. It is not a substitute for a cluster, but as a local development story for a Kubernetes destination it is the closest thing available.</p>



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



<p class="wp-block-paragraph">The Podman vs Docker decision is not really about container engines. Both run OCI images and both will start your workload. It is about whether a root daemon and a root-equivalent group are acceptable in your environment, and whether you have the weeks to spend on the four things that break.</p>



<p class="wp-block-paragraph">If a policy requirement is driving the question, switch and budget properly for the compose and socket work. If you are on an enterprise Linux distribution and starting something new, start on Podman, because you will be swimming with the current. If Docker is working and nothing external is forcing the question, the correct engineering answer is to leave it alone and go fix something that is actually broken.</p>



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



<h2 class="wp-block-heading">Thinking about a migration and want a second opinion?</h2>



<p class="wp-block-paragraph">I help teams decide this question and then execute it without a surprise outage. Things I am usually brought in for:</p>



<ul class="wp-block-list">
<li>Auditing what on your hosts actually depends on the Docker socket, and scoping the migration honestly before anyone commits to it</li>

<li>Testing your real compose files against the Podman socket and reporting what breaks, rather than guessing from a feature table</li>

<li>Converting long-lived services to Quadlet units with proper systemd ordering, health gating and journald logging</li>

<li>Sorting out rootless networking: privileged ports, reverse proxy placement, and the source-IP behaviour that surprises people</li>

<li>Closing the &#8220;docker group equals root&#8221; audit finding, whether that means Podman, rootless Docker, or removing the group entirely</li>

<li>Writing the decision up so it survives the next person who asks why you chose what you chose</li>
</ul>



<p class="wp-block-paragraph">If you are weighing this up, send me a compose file and a list of what mounts the socket. That is usually enough to tell you whether the switch is a fortnight or a quarter.</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/podman-vs-docker-worth-it/">Podman vs Docker: Is the Switch Actually Worth It?</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></content:encoded>
					
		
		
			</item>
	</channel>
</rss>
