<?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>S3 Lifecycle Rules | John Nessime</title>
	<atom:link href="https://john-nessime.com/blog/tag/s3-lifecycle-rules/feed/" rel="self" type="application/rss+xml" />
	<link>https://john-nessime.com/blog/tag/s3-lifecycle-rules/</link>
	<description>Cloud, DevOps, Data &#38; AI — Built, Tested, Explained</description>
	<lastBuildDate>Mon, 14 Sep 2026 13:13:06 +0000</lastBuildDate>
	<language>en-US</language>
	<sy:updatePeriod>
	hourly	</sy:updatePeriod>
	<sy:updateFrequency>
	1	</sy:updateFrequency>
	<generator>https://wordpress.org/?v=7.1</generator>

<image>
	<url>https://john-nessime.com/blog/wp-content/uploads/2026/07/cropped-jn-32x32.png</url>
	<title>S3 Lifecycle Rules | John Nessime</title>
	<link>https://john-nessime.com/blog/tag/s3-lifecycle-rules/</link>
	<width>32</width>
	<height>32</height>
</image> 
	<item>
		<title>Cloudflare R2 vs Amazon S3 for Media Offloading: Where the Bill Actually Comes From</title>
		<link>https://john-nessime.com/blog/cloud-computing/cloudflare-r2-vs-amazon-s3-media-offloading/</link>
		
		<dc:creator><![CDATA[John Nessime]]></dc:creator>
		<pubDate>Thu, 10 Sep 2026 06:00:00 +0000</pubDate>
				<category><![CDATA[Cloud Computing]]></category>
		<category><![CDATA[Web Performance]]></category>
		<category><![CDATA[WordPress]]></category>
		<category><![CDATA[Amazon S3]]></category>
		<category><![CDATA[AWS]]></category>
		<category><![CDATA[Cache Control]]></category>
		<category><![CDATA[Caching]]></category>
		<category><![CDATA[CDN]]></category>
		<category><![CDATA[Cloudflare]]></category>
		<category><![CDATA[Cost Optimization]]></category>
		<category><![CDATA[Data Residency]]></category>
		<category><![CDATA[FinOps]]></category>
		<category><![CDATA[Object Cache]]></category>
		<category><![CDATA[Presigned URLs]]></category>
		<category><![CDATA[S3 Lifecycle Rules]]></category>
		<category><![CDATA[Storage]]></category>
		<category><![CDATA[Vendor Lock-In]]></category>
		<category><![CDATA[Website Performance]]></category>
		<category><![CDATA[WP-CLI]]></category>
		<guid isPermaLink="false">https://john-nessime.com/blog/?p=382</guid>

					<description><![CDATA[<p>Free egress is not free serving. R2 still bills every read, S3 does not bill origin transfer to CloudFront, and the variable that decides both bills is cache hit ratio. An evenhanded comparison of Cloudflare R2 vs Amazon S3 for media offloading, with the four levers that move the number and a decision procedure you can run against your own traffic.</p>
<p>The post <a href="https://john-nessime.com/blog/cloud-computing/cloudflare-r2-vs-amazon-s3-media-offloading/">Cloudflare R2 vs Amazon S3 for Media Offloading: Where the Bill Actually Comes From</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 disk-full alert was the easy part. A media library outgrows the box it lives on, someone moves it to object storage over a weekend, the alert stops firing, everyone moves on. Two weeks later the complaint is different: images are slow for visitors on the other side of the planet, and there is a line on the bill nobody can explain item by item.</p>



<p class="wp-block-paragraph">Moving the files is the solved part. Which bucket they land in, and what sits in front of that bucket, decides whether you spend almost nothing or spend more than the server you were trying to shrink.</p>



<p class="wp-block-paragraph">Cloudflare R2 vs Amazon S3 usually gets reduced to one sentence: R2 does not charge for data transfer out, S3 does, therefore R2. That is wrong in both directions, and understanding why is most of the value here. This post covers the failure mode that shows up after the migration rather than during it, an honest profile of each service, the levers that actually move the number, and a procedure you can run against your own traffic in an afternoon.</p>



<h2 class="wp-block-heading">Free egress is not free serving</h2>



<p class="wp-block-paragraph">R2 charges nothing to move bytes out, on any storage class. It still charges for the read. Every <code>GetObject</code> and every <code>HeadObject</code> is a Class B operation, and a media library made of thousands of small files racks those up fast. Cloudflare&#8217;s own asset hosting example in the R2 pricing docs makes the point: storage falls inside the free tier, writes fall inside the free tier, and the whole bill is read operations.</p>



<p class="wp-block-paragraph">So the meter is not gigabytes. The meter is requests that reach the bucket, which means the variable deciding your bill on either platform is cache hit ratio.</p>



<p class="wp-block-paragraph">This is where offloading setups go wrong without anyone noticing. The plugin rewrites image URLs to point at the bucket, images render, pages look fine, and every request for every thumbnail travels all the way to the origin and gets billed. Nothing breaks. Nothing alerts. The number just grows with traffic.</p>



<p class="wp-block-paragraph">There is a sharper version on the R2 side. A new bucket gives you a public URL on an <code>r2.dev</code> subdomain, which is convenient and tempting. Cloudflare documents it as a testing endpoint with a variable rate limit. Push production traffic through it and requests get throttled with <code>429 Too Many Requests</code>, throughput can be throttled too, and you get no cache, no WAF, no bot management. Those only exist once the bucket sits behind a custom domain you control. That is the invisible failure: not a broken image, a working setup billed on every request and rate limited under load.</p>



<h2 class="wp-block-heading">Cloudflare R2: where it wins and where it hurts</h2>



<h3 class="wp-block-heading">Where R2 wins</h3>



<ul class="wp-block-list">
<li><strong>Egress really is zero.</strong> Not zero within a partner network, not zero up to a ratio of stored data. Zero, on both storage classes, through the S3 API and through Workers.</li>

<li><strong>A custom domain puts it behind the CDN.</strong> One managed CNAME and reads start being absorbed at the edge instead of hitting the bucket. No second product to buy and wire up.</li>

<li><strong>Deletes are free operations</strong>, along with aborting a multipart upload. Cleaning up a messy library costs nothing.</li>

<li><strong>Unauthorized requests are not billed.</strong> A caller without permission gets a 401 and you are not charged, which matters once someone starts hammering your bucket path.</li>

<li><strong>Migration tooling is free to use.</strong> Super Slurper copies a bucket across in bulk, Sippy migrates lazily on first miss. You pay only for the operations they perform against R2.</li>
</ul>



<h3 class="wp-block-heading">Where R2 hurts</h3>



<ul class="wp-block-list">
<li><strong>S3 compatible is not S3 identical.</strong> Cloudflare publishes a table of which operations are implemented and which are not. Real tooling has broken on the gaps: when AWS shipped SDKs that enabled CRC32 checksums by default, R2 rejected the header until the mismatch was resolved.</li>

<li><strong>There is no cold archive tier.</strong> Infrequent Access is as cold as it gets, and it carries a minimum storage duration, a retrieval fee, and doubled operation rates. For an active library that is the wrong tier, not a saving.</li>

<li><strong>Data lives in one primary location.</strong> Location hints are best effort rather than a region guarantee, and are honored only the first time a bucket with that name is created. Read performance away from that location comes from the edge cache, not from replicas.</li>

<li><strong>Plugin support is thinner.</strong> WP Offload Media, the long-standing WordPress option, officially lists Amazon S3, DigitalOcean Spaces, and Google Cloud Storage. R2 users generally land on Media Cloud, Advanced Media Offloader, Next3 Offload, or the S3-Uploads route driven from WP-CLI.</li>
</ul>



<h2 class="wp-block-heading">Amazon S3: where it wins and where it hurts</h2>



<h3 class="wp-block-heading">Where S3 wins</h3>



<ul class="wp-block-list">
<li><strong>Origin transfer to CloudFront is not billed.</strong> This quietly demolishes the usual comparison. AWS waives data transfer from an AWS origin to CloudFront, so a properly built S3 media stack never pays the direct-to-internet rate on cached traffic.</li>

<li><strong>The CloudFront free allowance is permanent</strong>, covering a monthly volume of data transfer out and requests rather than expiring after twelve months. Small sites can sit inside it indefinitely.</li>

<li><strong>Event driven derivatives are mature.</strong> Upload fires a Lambda that generates thumbnails, strips metadata, converts formats, writes the result back. Hard to beat if your media pipeline does real work.</li>

<li><strong>A full storage ladder with real lifecycle transitions</strong>, down to deep archive. If a large chunk of your library is genuinely cold, S3 has somewhere cheap to park it.</li>

<li><strong>Governance depth.</strong> Object Lock, versioning, replication, IAM condition keys, CloudTrail data events. When an auditor asks for a control, S3 usually has a named feature for it.</li>
</ul>



<h3 class="wp-block-heading">Where S3 hurts</h3>



<ul class="wp-block-list">
<li><strong>The default configuration is the expensive one.</strong> Point image URLs at the bucket endpoint and every byte leaves at the internet rate with nothing cached in front of it. The cheap path has to be built deliberately.</li>

<li><strong>Four meters instead of two:</strong> storage, bucket requests, CDN requests, CDN data transfer, plus per-feature charges. Forecasting takes real effort.</li>

<li><strong>Geography changes the rate.</strong> Delivery costs more per gigabyte to some regions than others, so an audience shift moves your bill without you changing anything.</li>

<li><strong>Leaving costs money.</strong> Copying a large library out is billed transfer, once, in a lump. Usually small against ongoing savings, but it is a real number somebody has to approve.</li>
</ul>



<h2 class="wp-block-heading">How to decide between Cloudflare R2 vs Amazon S3</h2>



<p class="wp-block-paragraph">Four levers move the outcome. Everything else is noise.</p>



<h3 class="wp-block-heading">Cache hit ratio</h3>



<p class="wp-block-paragraph">The dominant term on both platforms, and the one people skip. At a high hit ratio the origin barely gets touched and both bills collapse toward the storage line. At a low hit ratio, R2 bills read operations and S3 bills origin GETs, and both climb with traffic. Media should be trivially cacheable because the files never change. If yours is not caching, that is a configuration problem worth fixing before you pick a vendor, because fixing it changes the answer.</p>



<h3 class="wp-block-heading">Object count, not library size</h3>



<p class="wp-block-paragraph">WordPress does not store one file per upload. It stores the original plus every registered image size, and themes and page builders happily register more. Ten thousand uploads can easily be sixty thousand objects, each one a write on migration and a read on a cache miss. Count them before you model anything:</p>



<pre class="wp-block-code"><code>cd /path/to/wordpress

# Objects you are about to create in the bucket
find wp-content/uploads -type f | wc -l

# Bytes
du -sh wp-content/uploads

# How much of that count is derivative sizes rather than originals
find wp-content/uploads -type f -regextype posix-extended 
  -regex '.*-[0-9]+x[0-9]+.(jpe?g|png|webp|avif)$' | wc -l</code></pre>



<p class="wp-block-paragraph">That last number is usually the surprise. If most of your object count is thumbnails, trimming unused registered sizes before migrating is the cheapest optimization available, and it shrinks writes, reads, and storage at once.</p>



<h3 class="wp-block-heading">How cold the library actually is</h3>



<p class="wp-block-paragraph">Most libraries are a long tail. A small set of recent files takes nearly all the traffic while the rest sits untouched for years. If that tail is large, S3 has somewhere genuinely cheap to put it and R2 does not. Be careful with R2&#8217;s Infrequent Access class here: it lowers the storage rate but raises both operation rates, adds a retrieval fee, and enforces a minimum duration whether you keep the object or not. It fits write-once, read-almost-never data, not a library anything still links to.</p>



<h3 class="wp-block-heading">What the rest of your stack already is</h3>



<p class="wp-block-paragraph">If your application already runs in AWS and generates derivatives with Lambda on upload, moving to R2 means rebuilding that pipeline against Workers or R2 event notifications. That is real work you do not get paid for. If your site is a VPS from somewhere like InterServer or Contabo with Cloudflare already in front of it, R2 is nearly free work: the DNS is there, the cache is there, and the bucket slots in behind a subdomain you already control.</p>



<h2 class="wp-block-heading">Setup details that change the answer</h2>



<p class="wp-block-paragraph">A few configuration choices matter more than the vendor choice. Get these wrong and the cheaper platform produces the bigger bill.</p>



<h3 class="wp-block-heading">Serve from a custom domain, always</h3>



<p class="wp-block-paragraph">On R2 this is the difference between a cached, protected asset host and a throttled test endpoint. Attach one from the dashboard or from Wrangler:</p>



<pre class="wp-block-code"><code>npx wrangler r2 bucket domain add my-media-bucket 
  --domain=cdn.example.com 
  --zone-id=&lt;YOUR_ZONE_ID&gt;

npx wrangler r2 bucket domain list my-media-bucket</code></pre>



<p class="wp-block-paragraph">Then disable public access on the <code>r2.dev</code> subdomain, or you have left a second uncached, unprotected door into the same objects.</p>



<h3 class="wp-block-heading">Verify the cache instead of assuming it</h3>



<p class="wp-block-paragraph">Assumptions about caching are where the money leaks. Ask the edge directly: request the same object twice and read the headers.</p>



<pre class="wp-block-code"><code># Cloudflare in front of R2
curl -sI https://cdn.example.com/2024/07/photo-1024x768.jpg 
  | grep -iE 'cf-cache-status|cache-control|age'

# CloudFront in front of S3
curl -sI https://cdn.example.com/2024/07/photo-1024x768.jpg 
  | grep -iE 'x-cache|cache-control|age'</code></pre>



<p class="wp-block-paragraph">First request, expect a miss. Second, expect a hit. If the second still reports a miss, or reports a status meaning the response was never eligible for caching, every image view is costing you an origin read. Fix that before you compare anything.</p>



<h3 class="wp-block-heading">Long cache lifetimes and versioned filenames</h3>



<p class="wp-block-paragraph">Uploaded media is immutable in practice. Nobody edits the bytes of <code>photo-1024x768.jpg</code>, they upload a new file. So cache lifetimes should be long and the object name should change when the content does. If you find yourself purging image caches regularly, the real problem is that your filenames are not versioned.</p>



<p class="wp-block-paragraph">On the Cloudflare side, Smart Tiered Cache is worth enabling for R2 origins. It routes edge misses through an upper tier data center close to your bucket instead of letting every edge location fetch independently, which cuts the number of requests reaching R2 at all.</p>



<h3 class="wp-block-heading">Clean up failed uploads</h3>



<p class="wp-block-paragraph">Large uploads use multipart. Interrupted ones leave orphaned parts that occupy billed storage and do not show up in a normal object listing. Both platforms support lifecycle rules for this, and on R2 you can manage them from Wrangler:</p>



<pre class="wp-block-code"><code>npx wrangler r2 bucket lifecycle list my-media-bucket

npx wrangler r2 bucket lifecycle add my-media-bucket 
  --name=expire-temp 
  --prefix=tmp/ 
  --expire-days=30</code></pre>



<p class="wp-block-paragraph">Configure an abort rule for incomplete multipart uploads on day one, whichever platform you land on. It is the most common source of storage you pay for and cannot see.</p>



<h2 class="wp-block-heading">A decision procedure you can run this afternoon</h2>



<ol class="wp-block-list">
<li><strong>Measure your current cache hit ratio</strong> for image paths, from analytics or a sampled read of access logs. Everything downstream depends on this number.</li>

<li><strong>Count objects, not gigabytes.</strong> Run the <code>find</code> commands above and separate originals from derivative sizes.</li>

<li><strong>Estimate monthly origin reads</strong> as total image requests multiplied by the miss rate. That, not your bandwidth, is what you feed into either pricing calculator.</li>

<li><strong>Treat writes as a one-off migration spike</strong> plus a modest steady rate. Migration is usually the largest write event the bucket ever sees.</li>

<li><strong>Price both against current published rates</strong> on the same day, using Cloudflare&#8217;s R2 calculator and the AWS pricing calculator. Rates move. Do not trust a number copied from a blog post, including this one.</li>

<li><strong>Test with a prefix before committing.</strong> Offload one year of uploads, point the site at it, watch cache status headers and the operations dashboard for a week. A week of real traffic beats any spreadsheet.</li>
</ol>



<p class="wp-block-paragraph">If it comes out close, take R2. Not because it is cheaper in that scenario, but because it has fewer meters to reason about, and a simpler operational model is worth something on the day something breaks.</p>



<h2 class="wp-block-heading">Arguments that don&#8217;t survive contact</h2>



<h3 class="wp-block-heading">&#8220;S3 charges egress, R2 doesn&#8217;t, so R2 always wins&#8221;</h3>



<p class="wp-block-paragraph">Only true against the worst possible S3 setup. Transfer from an S3 bucket to CloudFront is not billed, so a properly built S3 media stack pays CDN delivery rates on cache misses, not the direct-from-bucket rate. Add CloudFront&#8217;s permanent free allowance and a small site pays nothing on either platform. The gap opens at scale and on origin-heavy traffic, not on the first terabyte.</p>



<h3 class="wp-block-heading">&#8220;R2 is a CDN&#8221;</h3>



<p class="wp-block-paragraph">R2 is object storage that can sit behind a CDN you already have. The caching, the WAF, the bot rules all come from the Cloudflare zone, and none of it applies to the development URL. If the bucket is not behind a custom domain on a zone you control, you have storage without delivery.</p>



<h3 class="wp-block-heading">&#8220;S3 compatible means drop-in&#8221;</h3>



<p class="wp-block-paragraph">It means most tools work with a changed endpoint and a region value of <code>auto</code>. It does not mean every operation, header, and checksum behaves identically. Budget an afternoon for fighting an SDK default, and test uploads, multipart uploads, deletes, and signed URLs against the real bucket before cutting over.</p>



<h3 class="wp-block-heading">&#8220;We&#8217;ll save money moving cold files to Infrequent Access&#8221;</h3>



<p class="wp-block-paragraph">Sometimes. The lower storage rate arrives with higher operation rates, a retrieval fee, and a minimum duration. If files are cold enough to justify that, they are probably cold enough to belong in a real archive tier, which R2 does not have. Model it against your read pattern rather than assuming a cheaper per-gigabyte number is a cheaper bill.</p>



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



<h3 class="wp-block-heading">Is Cloudflare R2 cheaper than S3 for a WordPress media library?</h3>



<p class="wp-block-paragraph">Usually, once traffic is meaningful and the library is served publicly. For a small site behind a well configured CDN, both can land near zero. R2&#8217;s advantage grows with egress volume and with the number of requests that miss cache, which is why measuring hit ratio comes first.</p>



<h3 class="wp-block-heading">Can I use the same WordPress plugin for both?</h3>



<p class="wp-block-paragraph">Several plugins speak the S3 API and support both, including Media Cloud, Advanced Media Offloader, and Next3 Offload. WP Offload Media officially lists Amazon S3, DigitalOcean Spaces, and Google Cloud Storage, so check current provider support before assuming a swap is free. Whichever you pick, confirm it rewrites existing URLs and not only new uploads.</p>



<h3 class="wp-block-heading">How do I migrate an existing S3 bucket to R2 without downtime?</h3>



<p class="wp-block-paragraph">Cloudflare offers two paths. Super Slurper copies everything in bulk. Sippy migrates incrementally, pulling an object from the source the first time it is requested and serving from R2 afterward. Both are free to use, you pay for the operations they perform against R2, and your source bucket may charge you for the reads.</p>



<h3 class="wp-block-heading">Do I still need a CDN in front of R2?</h3>



<p class="wp-block-paragraph">You need a custom domain on a Cloudflare zone, which is what puts the CDN in front of it. That is not optional for production. The development URL is rate limited by design and gets no cache, WAF, or bot management.</p>



<h3 class="wp-block-heading">Can I keep S3 and just put Cloudflare in front of it?</h3>



<p class="wp-block-paragraph">You can, and it does cut origin reads. Be aware that cache misses then pull from S3 across the public internet, billed as ordinary S3 egress, unlike an S3 origin sitting behind CloudFront. Reasonable as an interim step, rarely the cheapest end state.</p>



<h3 class="wp-block-heading">What about Backblaze B2, Wasabi, or DigitalOcean Spaces?</h3>



<p class="wp-block-paragraph">All viable and worth pricing if you are already modelling. B2 competes on raw storage, Wasabi sells predictability, Spaces is convenient when your droplets are already there. The analysis transfers directly: find the meters, find your cache hit ratio, multiply.</p>



<h3 class="wp-block-heading">How do I monitor the bill after migrating?</h3>



<p class="wp-block-paragraph">Watch operations and cache hit ratio, not storage. Storage grows slowly and predictably. Operations track traffic and configuration mistakes, which is where the unpleasant surprises live. A Grafana Cloud dashboard or a scheduled pull from the provider&#8217;s usage API is enough; the point is that somebody looks weekly rather than at invoice time.</p>



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



<p class="wp-block-paragraph">If you take one thing from this Cloudflare R2 vs Amazon S3 comparison, take this: the vendor choice is second order. What decides your media offloading bill is how many requests reach the bucket, and that is a function of your cache configuration, your object count, and whether you put a real domain in front of the storage.</p>



<p class="wp-block-paragraph">Fix the cache first. Count the objects. Then price both, on the same day, against your own numbers. Most of the time R2 comes out ahead for public media, S3 comes out ahead when the library is entangled with AWS services or needs a genuine archive tier, and the difference is smaller than the internet suggests.</p>



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



<h2 class="wp-block-heading">Need help moving a media library without breaking it?</h2>



<p class="wp-block-paragraph">Media offloading looks like a plugin install and turns into a URL rewriting, caching, and permissions problem. I work with teams on the parts that are easy to get subtly wrong:</p>



<ul class="wp-block-list">
<li>Modelling R2 against S3 using your real object counts, request volumes, and cache hit ratio instead of a generic calculator</li>

<li>Planning and running the migration: bulk or incremental copy, URL rewriting, and a rollback path if the cutover misbehaves</li>

<li>Custom domain, cache rule, and header configuration so origin reads collapse instead of tracking your traffic</li>

<li>Bucket permissions, CORS, signed URL flows for private downloads, and lifecycle rules for orphaned multipart uploads</li>

<li>Trimming registered image sizes and derivative sprawl before migration so you stop paying to store thumbnails nothing links to</li>

<li>Dashboards for operations, cache hit ratio, and storage growth so the bill stops being a monthly surprise</li>
</ul>



<p class="wp-block-paragraph">Send me a <code>curl -I</code> of one of your image URLs and a rough object count, and I can usually tell you quickly whether you have a pricing problem or a caching problem.</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/cloudflare-r2-vs-amazon-s3-media-offloading/">Cloudflare R2 vs Amazon S3 for Media Offloading: Where the Bill Actually Comes From</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>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>
	</channel>
</rss>
