# Can a Stolen Backup Key Delete Your Backups? Keys and Bucket Locks

> On MinIO, a key without delete rights still overwrote backups, and a lock kept the old version. The R2 claims come from its docs, not from a test.

Published: 2026-11-06. Author: Milan Jovanović.

Canonical: https://milanjovanovic.tech/blog/r2-bucket-lock-backups

On MinIO, a stolen backup key deleted old backups unless it lacked delete rights.
A key without them still overwrote objects, and under a lock a delete by version id was refused and the old version stayed.
R2 documents no token without delete rights, so I'd assume an R2 key can delete, and I didn't run that.

I ran the key and lock tests in a lab under WSL2, with [**MinIO**](https://min.io/) in a container as the storage.
Nothing ran on real R2, B2, S3, or Hetzner.
Claims about those come from their docs and price pages, and I mark them.
The keys on this page protect the objects that `pg-backup` uploads, the script in my [**off-site backups guide**](https://milanjovanovic.tech/blog/off-site-backups-vps).

## What Can a Stolen Server Key Delete?

On MinIO, removing the delete right stops deletion and leaves overwriting allowed.
The keys were `admin`, `bucket-rw` (list, read, write, and delete on one bucket), `put+list`, and `put-only` (trimmed):

```text
operation                          admin          bucket-rw      put+list       put-only
read from another bucket           200            403 AccessDenied 403 AccessDenied 403 AccessDenied
overwrite old backup               200            200            200            200
delete old backup                  204            204            403 AccessDenied 403 AccessDenied
```

This server key policy ran on MinIO.
It keeps read because a put-only key broke `rclone copyto`:

```json
{"Version":"2012-10-17","Statement":[
 {"Effect":"Allow","Action":["s3:ListBucket"],"Resource":["arn:aws:s3:::shop-backups"]},
 {"Effect":"Allow","Action":["s3:GetObject","s3:PutObject"],"Resource":["arn:aws:s3:::shop-backups/*"]}]}
```

That key can read every backup, so a stolen server key is also a copy of your data.

Encrypting with [**age**](https://github.com/FiloSottile/age) on the server would address that, though a lost key means lost backups, and I didn't wire it into `pg-backup` or test a lost key.
The same key can also add a junk object that looks newest, and my [**freshness check**](https://milanjovanovic.tech/blog/alert-when-backup-stops-running) accepts it.
The restore commands in my [**restore drill guide**](https://milanjovanovic.tech/blog/test-postgres-backup-restore) pick the newest object, so they would pick that junk (trimmed):

```text
OK zz-not-a-backup, 60000000 bytes, 0 s old, 2 objects
junk newest, freshness exit 0
restore step would pick: zz-not-a-backup
```

A killed multipart rclone upload left parts that the no-delete key couldn't list or remove.
An admin key cleared them with `rclone backend cleanup offsite:shop-backups -o max-age=1s`, while plain `rclone cleanup` left that young upload in place.
`offsite` is the rclone remote from my [**off-site backups guide**](https://milanjovanovic.tech/blog/off-site-backups-vps).
R2's docs say it expires incomplete uploads after seven days, so on other providers add a rule that aborts them.

## What Does a Bucket Lock Change?

The commands below created my MinIO bucket under `COMPLIANCE`, and they are for MinIO only.
Two warnings first.

**Compliance warning:** on AWS or B2, compliance mode can't be shortened or removed ([**AWS**](https://docs.aws.amazon.com/AmazonS3/latest/userguide/object-lock.html), [**B2**](https://www.backblaze.com/docs/cloud-storage-object-lock)).
I tested neither.
The data stays until it expires, so I'd expect to keep paying for it.
I'd start with governance mode and a one-day period.
On a separate MinIO bucket with that setting, a delete by version id was refused, and only a key with `s3:*` on the bucket or the admin key could delete, using the bypass header.

**Lifecycle warning:** the `mc ilm` rule deletes by age and doesn't check newer objects.
`rclone delete --min-age` has the same weakness.
If backups fail for 30 days, the last good one expires, so the failure alerts and the [**freshness check**](https://milanjovanovic.tech/blog/alert-when-backup-stops-running) have to reach you before then.

The rule also keeps running after a server dies, so turn it off at the provider when you start a recovery.
My rule never reached its date, so it never fired.

I'd prune at the provider, because a server key that can prune can also delete.
The tradeoff: the provider rule is age-only.
The prune in `pg-backup` runs after a verified upload, so failed runs deleted nothing.

Here are the MinIO commands, with my lab's values, not the ones I'd start with.
`mc` is the [**MinIO client**](https://docs.min.io/community/minio-object-store/reference/minio-mc.html), `adm` is my `mc alias` for the MinIO admin user, and `shop-backups` is my lab's bucket name:

```bash
mc mb --ignore-existing --with-lock adm/shop-backups
mc retention set --default COMPLIANCE 7d adm/shop-backups
mc ilm rule add --expire-days 30 --noncurrent-expire-days 1 adm/shop-backups
```

This table puts the MinIO results under `COMPLIANCE` next to R2.
The R2 column comes from its [**S3 API page**](https://developers.cloudflare.com/r2/api/s3/api/) and [**bucket lock page**](https://developers.cloudflare.com/r2/buckets/bucket-locks/), and none of this ran on R2.
The delete row ran on a separate MinIO bucket with a 1-day default, not the 7-day bucket above:

|  | MinIO, tested | R2, from its docs, not run |
| --- | --- | --- |
| Overwrite under a lock | Allowed, and the old version stays | Refused |
| Delete under a lock | Delete by version id refused, even with delete rights. A plain delete adds a marker, and a copy by version id matched the original hash | Refused |
| Versioning | On | Listed as unsupported |
| Removing the lock rule | Not tested | Dashboard, Wrangler, or an API token that edits bucket configuration, so keep that token off the server |
| Key without delete rights | The policy above | No documented no-delete token, so I would assume the key can delete |

On R2 I'd try a 7-day lock rule, a 30-day lifecycle rule, and an `Object Read & Write` token scoped to the one bucket, set up from its [**bucket lock page**](https://developers.cloudflare.com/r2/buckets/bucket-locks/), and I didn't run any of that.
Cloudflare says locks take precedence, so keep the lifecycle rule longer than the lock.

If that token can delete, the lock protects only the last 7 days of backups from a stolen key.
That is my reading of the docs, and I didn't test it.

## Which Storage Should I Use, and What Does It Cost?

I have no tested pick among these.
I'd choose on key scope and lock behavior, because those decide what a stolen server key can do.
These are list prices read on the dates shown ([**R2**](https://developers.cloudflare.com/r2/pricing/), [**B2**](https://www.backblaze.com/cloud-storage/pricing), [**AWS S3**](https://aws.amazon.com/s3/pricing/), [**Hetzner**](https://www.hetzner.com/storage/object-storage/)), so check them before you commit.
The key scope comes from the [**R2 token page**](https://developers.cloudflare.com/r2/api/tokens/), the [**B2 key page**](https://www.backblaze.com/docs/cloud-storage-application-key-capabilities), and the [**Hetzner credentials page**](https://docs.hetzner.com/storage/object-storage/faq/s3-credentials/).

| Provider | Storage | Egress | Key scope, from docs | Read |
| --- | --- | --- | --- | --- |
| Cloudflare R2 | $0.015 per GB-month, 10 GB-month free. Class A $4.50, Class B $0.36 per million | Free | Object Read & Write token for one bucket. The docs do not say whether it can delete. No list-only token, and the narrowest one can also read | Nov 6, 2026 |
| Backblaze B2 | $6.95 per TB per month, first 10 GB free | Free up to 3x average storage, then $0.01 per GB | One bucket. Give it listFiles, readFiles, and writeFiles, without deleteFiles and bypassGovernance | Nov 6, 2026 |
| AWS S3 Standard, us-east-1 | $0.023 per GB-month | $0.09 per GB | Not checked | Nov 5, 2026 |
| Hetzner Object Storage | EUR 6.49 per month excl. VAT, with 1 TB of storage | 1 TB included | Valid for every bucket in the project by default, so use a separate project or a bucket policy | Nov 5, 2026 |

Thirty nightly copies of my 92 MB dump come to about 2.8 GB.
That is under the free tiers listed for R2 and B2.
Hetzner's base price applies at any size.

## Summary

On MinIO, a key without delete rights could not delete, but it could overwrite, add junk that looks newest, and read every backup.
Under `COMPLIANCE`, the lock refused a delete by version id and kept the old version after an overwrite.

The R2 column comes from its docs, the lifecycle rule never fired, and I have no tested pick among the providers.
Try your own token and lock rule on your real provider before you count on them, and keep the lifecycle rule longer than the lock.

The full backup script is in my [**off-site backups guide**](https://milanjovanovic.tech/blog/off-site-backups-vps), and my [**self-hosting course**](https://milanjovanovic.tech/pragmatic-self-hosting) sets up database backups to storage outside the server.

Thanks for reading.

And stay awesome!

---

## Frequently asked questions

### Can a stolen backup key delete my backups?

On MinIO, a key with delete rights deleted an old backup, and a key without them got 403 AccessDenied. Both could still overwrite it. R2 documents no token without delete rights, so I would assume an R2 key can delete. I did not run this on R2.

### Does a bucket lock stop a stolen key?

On MinIO under COMPLIANCE, a delete by version id was refused even with delete rights, and an overwrite kept the old version. The R2 docs say overwrites and deletes under a lock are refused, and I did not run that.

### Can I shorten or remove a compliance lock?

Per the AWS and Backblaze docs, compliance mode cannot be shortened or removed, and I tested neither. The data stays until it expires, so I would start with governance mode and a one-day period.

### What does off-site storage cost for a small dump?

Thirty nightly copies of my 92 MB dump come to about 2.8 GB, which is under the free tiers listed for R2 and B2 on November 6, 2026. Hetzner's base price applies at any size. These are list prices, so check them before you commit.
