Upload Rate Limiting & Abuse Protection

An upload endpoint is the only route in most applications where an anonymous request can permanently spend your money: bytes on a disk you rent, egress through a CDN you are billed for, and CPU in a transcoder you scale on demand. Nothing about a flood of uploads looks like an attack — every request is well-formed, correctly authenticated, and does exactly what the API was built to do, which is why the bill arrives before the alert does.

This guide treats upload capacity as a scarce resource with a budget, and shows how to enforce that budget at three independent layers: the edge, the API that issues credentials, and the storage policy that the client cannot forge. It sits under Backend Validation & Cloud Storage Architecture and assumes you are already issuing credentials with S3 presigned URL workflows — the moment you hand a browser a signed URL, you have handed it a licence to write, and the only question left is how much.

Prerequisites

  • [ ] Node 20+ with @aws-sdk/client-s3, @aws-sdk/s3-presigned-post and ioredis installed
  • [ ] Redis 7 (or Valkey 7) reachable from every API instance — the limiter state must be shared, not per-process
  • [ ] A Postgres database you can add one table and one counter to
  • [ ] An S3 bucket with s3:ObjectCreated:* event notifications wired to a queue or Lambda
  • [ ] Permission to edit your CDN or WAF rules (Cloudflare, CloudFront + AWS WAF, or an Nginx you control)
  • [ ] A working presigned upload flow — see generating secure presigned URLs with AWS SDK v3
  • [ ] curl 7.75+ locally, for replaying refused requests against the real bucket

The threat model

Abuse of an upload path rarely involves an exploit. It involves using the feature correctly, at a rate or size you never budgeted for. Five patterns account for almost everything you will see in production.

Storage exhaustion. A script signs up, requests presigned URLs in a loop, and writes 4 MB objects as fast as the network allows. A single residential connection at 200 Mbit/s commits about 90 GB an hour. Ten of them fill a terabyte before lunch. Nothing is malformed; you simply agreed to store it.

Bandwidth and request billing attacks. Storage is cheap compared to the operations around it. On S3 at typical us-east-1 pricing, 10 million PUT requests cost about $50 in request charges alone, before a byte of storage. If your pipeline fans each object out to a virus scanner, a thumbnailer and a replica bucket, one uploaded megabyte can generate four or five billable reads. An attacker optimising for your invoice rather than your disk will upload many small objects, not a few large ones.

Decompression bombs. A 42 KB zip archive that expands to 4.5 PB has existed since 2001, and the modern equivalents are quieter: a 6 KB PNG that decodes to a 60,000 × 60,000 pixel surface, or a gzip stream with a 1000:1 ratio. The upload passes every size check because the compressed size is tiny. The damage happens later, in the worker that opens it.

Credential farming from over-broad presigned URLs. If your issuance endpoint signs PutObject for uploads/* with a 24-hour expiry and no size condition, one leaked URL is a write credential for your bucket. Attackers scrape these out of client-side logs, shared HAR files and public Sentry breadcrumbs. The URL does not need to be stolen from you — you gave it to the browser.

Concurrency exhaustion and noisy neighbours. A single tenant opening 500 simultaneous multipart uploads will saturate the connection pool your other tenants share, without exceeding any per-request limit at all. Rate is not the same as concurrency, and you must budget both.

Attack What it consumes The control that actually stops it
Object flood Storage, PUT requests Token bucket on issuance + per-tenant daily byte quota
Oversized single object Storage, egress content-length-range in a POST policy
Small-object request flood Request charges, queue depth Edge rate limit by IP/ASN + minimum object size
Decompression bomb Worker RAM, disk Streamed inflate with a ratio and absolute output cap
Leaked presigned URL Storage, reputation 300-second expiry, one key per URL, size condition
Concurrency hogging Connections, transcoder slots In-flight semaphore per tenant

How it works

Three currencies, three budgets

Every upload spends three things and you must meter all three separately, because a limit on one says nothing about the others:

  1. Requests — how many times a caller may ask for a credential. Cheap to count, cheap to refuse, and the only budget the edge can see.
  2. Bytes — how much data may actually land. This is the budget that maps to your bill, and it is the hardest to enforce because the number is not known until the transfer finishes.
  3. Slots — how many transfers may be in flight at once. Bounds tail latency and connection-pool pressure rather than cost.

A limiter that only counts requests is trivially defeated: 10 requests per minute, each for a 5 GB object, is 50 GB per minute. A limiter that only counts bytes lets a caller mint a million presigned URLs it never uses, filling your reservation table. Budget all three or you have budgeted none.

Three enforcement layers between a client and a stored object A client passes an edge WAF rate limit, then an API limiter that reserves quota, then a signed POST policy, before bytes reach the object store; each layer rejects with a different status at a different cost. Three gates, three currencies Client or bot Edge / WAF IP + ASN buckets coarse, stateless API limiter user / tenant key reserves quota POST policy content-length- range, signed Object billable bytes 429 in 0.2 ms no origin cost 429 + Retry-After nothing signed 400 EntityTooLarge no bytes stored
Each gate refuses a different class of abuse, and the further left it refuses, the less the refusal costs you.

Token bucket versus sliding window

A fixed window counter (INCR key:2026-07-26T14:03, expire after 60 s) is the cheapest thing to build and the easiest to abuse: a caller who sends its full allowance at 14:03:59 and again at 14:04:00 achieves double the intended rate across a one-second boundary. For an endpoint that mints write credentials, that doubling matters.

A sliding-window log keeps a sorted set of request timestamps and trims anything older than the window. It is exact, but it stores one member per request — roughly 60–70 bytes each in Redis — so a tenant allowed 600 requests a minute costs 40 KB of RAM permanently, and the trim (ZREMRANGEBYSCORE) is O(log N + M).

A token bucket stores two numbers — a token count and a timestamp — refills lazily on read, and answers in O(1) with about 100 bytes per key. It has one property the others lack: burst and sustained rate are independent knobs. capacity: 20, refill: 0.5/s means a user who has been idle can immediately start 20 uploads, then settles to 30 an hour. That is what a real client does when someone drags a folder of photos onto the page, and it is why token bucket (or its equivalent formulation, GCRA) is the right default for upload issuance.

Token level over time for a burst of ten uploads A bucket of capacity ten drains to zero within two seconds of a ten-request burst, refuses two further requests, then refills linearly at half a token per second, reaching capacity again at twenty-five seconds. Burst of 10, then refill at 0.5 tokens/s tokens cap 10 5 0 2 refused (429) refill: 1 token every 2 s 0 10 20 30 seconds since first request
Capacity buys the drag-a-folder burst; the refill rate is what caps sustained abuse — tune them separately.

What to key on

Keys, in descending order of usefulness: tenant or organisation ID, authenticated user ID, API key, then IP address. IP is the weakest of the four and the only one available for anonymous uploads. Behind carrier-grade NAT, a single IPv4 address can front tens of thousands of mobile users, so a strict per-IP limit is a self-inflicted outage; on IPv6 an attacker gets a /64 by default, which is 18 quintillion addresses, so a per-address limit is free to evade. Key IPv6 on the /64 prefix, not the address, and treat IPv4 limits as generous circuit-breakers rather than precise budgets.

Evaluate several buckets per request and refuse if any of them is empty — user, tenant, and IP prefix. Refuse before you consume from the others, or a request rejected by the third bucket has still drained the first two.

Where the size limit actually binds

This is the part teams get wrong. A size check binds only if the party enforcing it cannot be bypassed by the party being limited:

  • file.size > MAX in JavaScript stops honest users from wasting their own upload time. It stops nobody else; curl does not run your bundle.
  • An API check on a client-declared size field binds nothing on its own. The declaration is just JSON, and unless the number is baked into the signature, S3 will happily accept a different one.
  • Signing ContentLength into a presigned PUT does bind — but as an exact value, not a range. Send one byte more or less and S3 answers SignatureDoesNotMatch, which is a terrible error message for “your file grew”. The trade-offs between the two are covered in presigned POST vs presigned PUT for browser uploads.
  • A content-length-range condition inside a presigned POST policy binds as a range, is signed by your server, and is checked by S3 before the object is committed. This is the only browser-friendly control that both permits a range and cannot be edited by the client.
Bytes billed when a 5 GB upload is refused, by control Four size controls compared: a client-side check and a trusted declared size both bill the full five gigabytes against a hostile client, while a signed Content-Length and a POST policy content-length-range bill nothing. Bytes you pay for when a 5 GB upload is refused honest client hostile client Client-side JS check 0 bytes 5.0 GB billed API trusts declared size 0 bytes 5.0 GB billed Signed Content-Length 0 bytes 0 bytes — SignatureDoesNotMatch POST content-length-range 0 bytes 0 bytes — EntityTooLarge Only a signed condition changes the hostile column.
Against an honest client every control looks identical; only the two signed conditions survive contact with curl.

Step-by-step implementation

1. Write down the budgets before writing code

Put the numbers in one module so the limiter, the POST policy and the quota accountant cannot disagree. Deriving the policy from the same constants as the limiter is how you avoid the classic bug where the API allows 100 MB and the bucket policy allows 25 MB.

// budgets.ts — the single source of truth for upload capacity.
export const MiB = 1024 * 1024;

export const BUDGETS = {
  /** Presign requests: burst of 20, sustained 30/minute. */
  issuance: { capacity: 20, refillPerSecond: 0.5, cost: 1 },
  /** Hard per-object ceiling, mirrored into every POST policy. */
  minObjectBytes: 1024,
  maxObjectBytes: 100 * MiB,
  /** Rolling 24 h byte allowance per tenant plan. */
  dailyBytes: { free: 2 * 1024 * MiB, pro: 200 * 1024 * MiB },
  /** Uploads a tenant may hold open at once. */
  concurrentUploads: { free: 4, pro: 24 },
  /** How long a reservation survives if the object never appears. */
  reservationTtlSeconds: 900,
  /** POST policy validity. Short enough that a leaked URL is worthless. */
  policyExpirySeconds: 300,
} as const;

export type PlanName = keyof typeof BUDGETS.dailyBytes;

2. Build an atomic token bucket in Redis

The refill-check-write sequence must be atomic or two concurrent requests both read “1 token left” and both proceed. A Lua script runs inside Redis as a single unit, so the read-modify-write cannot interleave. Take the clock from TIME inside Redis rather than from the caller — otherwise a single app server with 40 seconds of drift can refill everyone’s bucket for free.

-- token_bucket.lua
-- KEYS[1] = bucket key
-- ARGV[1] = capacity, ARGV[2] = tokens refilled per second
-- ARGV[3] = cost of this request, ARGV[4] = key TTL in seconds
local capacity = tonumber(ARGV[1])
local refill   = tonumber(ARGV[2])
local cost     = tonumber(ARGV[3])
local ttl      = tonumber(ARGV[4])

local clock = redis.call('TIME')                      -- {seconds, microseconds}
local now   = tonumber(clock[1]) + tonumber(clock[2]) / 1000000

local state  = redis.call('HMGET', KEYS[1], 'tokens', 'ts')
local tokens = tonumber(state[1])
local ts     = tonumber(state[2])
if tokens == nil then
  tokens = capacity
  ts = now
end

tokens = math.min(capacity, tokens + math.max(0, now - ts) * refill)

local allowed, retryAfterMs = 0, 0
if tokens >= cost then
  tokens = tokens - cost
  allowed = 1
else
  retryAfterMs = math.ceil(((cost - tokens) / refill) * 1000)
end

redis.call('HSET', KEYS[1], 'tokens', tokens, 'ts', now)
redis.call('EXPIRE', KEYS[1], ttl)

-- Redis converts Lua numbers to integers, so floor deliberately.
return { allowed, math.floor(tokens), retryAfterMs }

Load it once at boot with defineCommand, which hashes the script and uses EVALSHA on every subsequent call:

// limiter.ts
import { readFile } from "node:fs/promises";
import Redis from "ioredis";
import { BUDGETS } from "./budgets.js";

const redis = new Redis(process.env.REDIS_URL!, { maxRetriesPerRequest: 2 });
redis.defineCommand("tokenBucket", {
  numberOfKeys: 1,
  lua: await readFile(new URL("./token_bucket.lua", import.meta.url), "utf8"),
});

export interface Decision {
  allowed: boolean;
  remaining: number;
  retryAfterSeconds: number;
}

export async function takeToken(key: string, cost = 1): Promise<Decision> {
  const { capacity, refillPerSecond } = BUDGETS.issuance;
  const ttl = Math.ceil(capacity / refillPerSecond) * 2;
  try {
    const [allowed, remaining, retryMs] = (await (redis as unknown as {
      tokenBucket(k: string, c: number, r: number, cost: number, ttl: number): Promise<number[]>;
    }).tokenBucket(`rl:${key}`, capacity, refillPerSecond, cost, ttl));
    return {
      allowed: allowed === 1,
      remaining,
      retryAfterSeconds: Math.ceil(retryMs / 1000),
    };
  } catch (err) {
    // Redis is down. Fail open on issuance, but see "Gotchas" below —
    // the POST policy is still enforcing the byte ceiling.
    console.error("limiter unavailable, failing open:", err);
    return { allowed: true, remaining: -1, retryAfterSeconds: 0 };
  }
}

Running the burst by hand shows the shape:

$ node --experimental-strip-types drain.ts
req  1 → allow  remaining 19
req 19 → allow  remaining  1
req 20 → allow  remaining  0
req 21 → DENY   retry-after 2s
req 22 → DENY   retry-after 4s

3. Gate credential issuance, not the upload

Refuse at the point where you hand out the write credential. That request is small, cheap and hits your origin anyway, whereas the upload itself goes straight to S3 where your middleware never sees it. Evaluate every relevant bucket, then emit the standard headers so well-behaved clients back off instead of hammering.

// routes/presign.ts
import type { Request, Response } from "express";
import { takeToken } from "../limiter.js";

function ipKey(req: Request): string {
  const ip = req.ip ?? "0.0.0.0";
  if (!ip.includes(":")) return `ip4:${ip}`;
  // Key IPv6 on the /64 — a single customer owns the whole prefix.
  return `ip6:${ip.split(":").slice(0, 4).join(":")}`;
}

export async function presignHandler(req: Request, res: Response): Promise<void> {
  const { tenantId, userId } = req.auth;
  const buckets = [`tenant:${tenantId}`, `user:${userId}`, ipKey(req)];

  for (const key of buckets) {
    const decision = await takeToken(key);
    res.setHeader("RateLimit-Limit", String(20));
    res.setHeader("RateLimit-Remaining", String(Math.max(0, decision.remaining)));
    if (!decision.allowed) {
      res.setHeader("Retry-After", String(decision.retryAfterSeconds));
      res.setHeader("RateLimit-Reset", String(decision.retryAfterSeconds));
      res.status(429).json({
        error: "rate_limited",
        scope: key.split(":")[0],
        retryAfterSeconds: decision.retryAfterSeconds,
      });
      return;
    }
  }

  res.json(await issueUploadTicket(req.auth, req.body));
}

Note the ordering bug hiding in that loop: buckets consume in sequence, so a request refused by the IP bucket has already spent a tenant token. For low-cost issuance this is acceptable slack; if it is not, run a single Lua script that checks all three and only decrements when all three pass. The per-route variants, including how to price a multipart initiation differently from a single PUT, are worked through in rate limiting presigned URL issuance.

4. Make the size ceiling binding with a POST policy

createPresignedPost produces a form the browser submits directly to S3. The Conditions array is signed, so the browser cannot widen it; S3 evaluates every condition before writing the object. Three conditions matter here: the byte range, a key prefix that pins the object to this tenant, and metadata equality so an object cannot be attributed to somebody else’s account.

// ticket.ts
import { S3Client } from "@aws-sdk/client-s3";
import { createPresignedPost } from "@aws-sdk/s3-presigned-post";
import { randomUUID } from "node:crypto";
import { BUDGETS, type PlanName } from "./budgets.js";

const s3 = new S3Client({ region: process.env.AWS_REGION });

export async function issueUploadTicket(
  auth: { tenantId: string; plan: PlanName },
  body: { contentType: string; declaredBytes: number },
) {
  // Clamp the client's declaration; never trust it, but do use it to
  // reserve quota so a 90 MB upload cannot slip past a 5 MB reservation.
  const maxBytes = Math.min(BUDGETS.maxObjectBytes, Math.max(body.declaredBytes, 1) * 2);
  const key = `tenants/${auth.tenantId}/incoming/${randomUUID()}`;

  await reserveQuota(auth.tenantId, key, maxBytes); // step 5

  const { url, fields } = await createPresignedPost(s3, {
    Bucket: process.env.UPLOAD_BUCKET!,
    Key: key,
    Expires: BUDGETS.policyExpirySeconds,
    Conditions: [
      ["content-length-range", BUDGETS.minObjectBytes, maxBytes],
      ["eq", "$key", key],
      ["eq", "$x-amz-meta-tenant", auth.tenantId],
      ["starts-with", "$Content-Type", body.contentType.split("/")[0] + "/"],
    ],
    Fields: {
      "x-amz-meta-tenant": auth.tenantId,
      "x-amz-server-side-encryption": "AES256",
    },
  });

  return { url, fields, key, maxBytes, expiresIn: BUDGETS.policyExpirySeconds };
}

Two details that cost people an afternoon. First, ["eq", "$key", key] rather than starts-with — with a prefix condition the client picks the final segment and can overwrite a sibling object. Second, the file field must be appended last to the FormData; S3 stops parsing at the file part and ignores every field after it, producing a bewildering MissingFields error. The full matrix of conditions, including how content-length-range interacts with multipart, is in enforcing upload size limits with S3 POST policies.

5. Reserve on issue, settle on the storage event

You cannot know an object’s true size when you sign the credential, and you must not wait until it lands to find out — by then the bytes are already stored. The fix is a two-phase account: reserve the worst case at issuance, then correct the ledger when the storage event tells you what actually arrived.

Reservation lifecycle from issue to settlement A reservation created when the upload URL is issued either settles when the object-created event arrives, adjusting usage by the real size, or is released by a sweeper when its time to live expires. Reserve on issue, settle on the storage event reserved usage += maxBytes settled real size known released object never arrived usage += actual minus maxBytes usage -= maxBytes sweeper, every 60 s presign ObjectCreated TTL expired
Reserving the worst case means a burst of signed URLs consumes quota immediately, not eventually.

The schema puts the ceiling in a CHECK constraint, so an accounting bug cannot overshoot the plan — the database refuses the write rather than silently allowing a terabyte:

CREATE TABLE tenant_usage (
  tenant_id    text PRIMARY KEY,
  window_start date NOT NULL DEFAULT current_date,
  used_bytes   bigint NOT NULL DEFAULT 0,
  limit_bytes  bigint NOT NULL,
  CONSTRAINT usage_within_plan CHECK (used_bytes BETWEEN 0 AND limit_bytes)
);

CREATE TABLE upload_reservation (
  object_key     text PRIMARY KEY,
  tenant_id      text NOT NULL REFERENCES tenant_usage(tenant_id),
  reserved_bytes bigint NOT NULL,
  actual_bytes   bigint,
  state          text NOT NULL DEFAULT 'reserved',
  expires_at     timestamptz NOT NULL,
  created_at     timestamptz NOT NULL DEFAULT now()
);

CREATE INDEX reservation_sweep_idx ON upload_reservation (expires_at)
  WHERE state = 'reserved';
// quota.ts
import { pool } from "./db.js";
import { BUDGETS } from "./budgets.js";

export async function reserveQuota(tenantId: string, key: string, bytes: number) {
  const client = await pool.connect();
  try {
    await client.query("BEGIN");
    // Rolls the window over lazily; no cron needed for the daily reset.
    await client.query(
      `UPDATE tenant_usage
          SET used_bytes = CASE WHEN window_start < current_date THEN 0 ELSE used_bytes END,
              window_start = current_date
        WHERE tenant_id = $1`,
      [tenantId],
    );
    await client.query(
      "UPDATE tenant_usage SET used_bytes = used_bytes + $2 WHERE tenant_id = $1",
      [tenantId, bytes],
    );
    await client.query(
      `INSERT INTO upload_reservation (object_key, tenant_id, reserved_bytes, expires_at)
       VALUES ($1, $2, $3, now() + ($4 || ' seconds')::interval)`,
      [key, tenantId, bytes, BUDGETS.reservationTtlSeconds],
    );
    await client.query("COMMIT");
  } catch (err) {
    await client.query("ROLLBACK");
    if ((err as { constraint?: string }).constraint === "usage_within_plan") {
      throw Object.assign(new Error("daily upload quota exhausted"), { status: 429 });
    }
    throw err;
  } finally {
    client.release();
  }
}

/** Called from the s3:ObjectCreated consumer. Idempotent by object key. */
export async function settleQuota(key: string, actualBytes: number) {
  await pool.query(
    `WITH r AS (
       UPDATE upload_reservation
          SET state = 'settled', actual_bytes = $2
        WHERE object_key = $1 AND state = 'reserved'
        RETURNING tenant_id, reserved_bytes
     )
     UPDATE tenant_usage u
        SET used_bytes = GREATEST(0, u.used_bytes - r.reserved_bytes + $2)
       FROM r WHERE u.tenant_id = r.tenant_id`,
    [key, actualBytes],
  );
}

The sweeper is four lines of SQL on a 60-second timer, and it is what stops a client that requests a thousand URLs and uploads none from freezing an account for a day. Pair it with an S3 lifecycle rule so the storage side self-cleans too — see setting up S3 lifecycle rules for temporary uploads.

6. Cap expansion before you expand anything

Size limits protect the bytes on the wire. They do not protect the worker that opens the file, because compression ratios are unbounded. Never decompress into memory or onto disk without a running byte counter and a hard ceiling; check the ratio as you go rather than trusting the declared uncompressed size in a zip central directory, which the attacker writes.

// safe-inflate.ts
import { createGunzip } from "node:zlib";
import { Transform } from "node:stream";
import { pipeline } from "node:stream/promises";
import type { Readable, Writable } from "node:stream";

export class ExpansionLimitError extends Error {}

export async function inflateWithLimit(
  source: Readable,
  sink: Writable,
  opts: { compressedBytes: number; maxRatio?: number; maxOutputBytes?: number },
): Promise<number> {
  const maxRatio = opts.maxRatio ?? 100;
  const maxOutput = opts.maxOutputBytes ?? 512 * 1024 * 1024;
  let written = 0;

  const guard = new Transform({
    transform(chunk: Buffer, _enc, done) {
      written += chunk.length;
      if (written > maxOutput) {
        done(new ExpansionLimitError(`output ${written} B exceeds cap ${maxOutput} B`));
        return;
      }
      const ratio = written / Math.max(1, opts.compressedBytes);
      if (ratio > maxRatio) {
        done(new ExpansionLimitError(
          `inflate ratio ${ratio.toFixed(0)}:1 exceeds max ${maxRatio}:1`,
        ));
        return;
      }
      done(null, chunk);
    },
  });

  await pipeline(source, createGunzip(), guard, sink);
  return written;
}

Feed it a 42 KB bomb and it dies after 4.2 MB with ExpansionLimitError: inflate ratio 101:1 exceeds max 100:1, having touched a rounding error’s worth of RAM. Images need the same treatment in pixel space rather than byte space: sharp(buf, { limitInputPixels: 100_000_000 }) throws Input image exceeds pixel limit before allocating the surface. The archive-specific traps — nested zips, zip64 headers that lie, and per-entry limits — are covered in detecting and blocking zip bomb uploads, and the format-sniffing that must run first is in server-side file validation.

7. Push the coarse limit to the edge

Everything above runs at your origin, which means an attacker still gets to consume a TLS handshake, a connection slot and a few milliseconds of event loop per refusal. Move the blunt instrument upstream. In Nginx, two zones cover it:

# 10 MB of shared memory tracks roughly 160,000 distinct keys.
limit_req_zone $binary_remote_addr zone=presign_ip:10m rate=30r/m;
limit_req_zone $http_x_tenant_id   zone=presign_tenant:10m rate=600r/m;
limit_conn_zone $binary_remote_addr zone=upload_conns:10m;

server {
  client_max_body_size 100m;

  location /api/uploads/presign {
    limit_req zone=presign_ip burst=20 nodelay;
    limit_req zone=presign_tenant burst=100 nodelay;
    limit_conn upload_conns 12;
    limit_req_status 429;
    limit_conn_status 429;
    proxy_pass http://app_upstream;
  }
}

burst=20 nodelay is the Nginx spelling of a token bucket: 20 tokens of headroom, refilled at 30 per minute, served immediately rather than queued. Keep client_max_body_size aligned with maxObjectBytes for any proxied upload path, or the proxy returns 413 Request Entity Too Large after the client has already sent the body — the tuning is detailed in raising Nginx and Cloudflare upload size limits.

Configuration reference

Key Type Default Effect
issuance.capacity integer 20 Tokens available to an idle caller; the size of the allowed burst.
issuance.refillPerSecond float 0.5 Sustained rate. 0.5 is 30 presign requests per minute.
issuance.cost integer 1 Tokens charged per request. Charge multipart initiation more.
minObjectBytes integer 1024 Lower bound in content-length-range; blocks zero-byte object floods.
maxObjectBytes integer 104857600 Upper bound in content-length-range. S3 answers EntityTooLarge above it.
dailyBytes.<plan> integer 2 GiB / 200 GiB Rolling 24-hour byte allowance, enforced by the usage_within_plan constraint.
concurrentUploads.<plan> integer 4 / 24 Reservations a tenant may hold in reserved state simultaneously.
reservationTtlSeconds integer 900 How long unused quota stays held. Must exceed your slowest legitimate upload.
policyExpirySeconds integer 300 POST policy validity. Beyond it S3 returns AccessDenied: Policy expired.
maxRatio integer 100 Output-to-input ratio at which decompression aborts.
maxOutputBytes integer 536870912 Absolute inflate ceiling, independent of ratio.
limit_req burst integer 20 Edge burst tolerance. Without nodelay requests queue instead of failing.

Edge cases and gotchas

Shared NAT makes IP a blunt instrument

One university, one mobile carrier or one corporate VPN can put 50,000 users behind a single IPv4 address. A per-IP limit of 30 requests a minute will look correct in staging and page you at 09:00 when a school starts a photo assignment. Treat the IP bucket as a circuit-breaker set an order of magnitude above the user bucket, key IPv6 on the /64 prefix, and always prefer the authenticated identity when you have one. If you must limit anonymous uploads by IP, pair it with a proof-of-work or challenge rather than a tighter number.

Clock skew breaks both the limiter and the policy

Two failures share one root cause. If each app server passes its own Date.now() into the bucket script, a server 40 seconds fast credits every caller 20 free tokens on the next call; reading TIME inside Redis eliminates the whole class of bug. On the S3 side, a browser with a skewed clock is harmless — the policy carries your server’s x-amz-date — but a server with a skewed clock produces policies that S3 rejects with AccessDenied: Invalid according to Policy: Policy expired. even though the URL is seconds old. Run NTP on the signer and keep policyExpirySeconds at 300 rather than 30 so a few seconds of drift is not fatal.

Multipart uploads walk around a single-object limit

content-length-range constrains one POST. It does not constrain a multipart upload, where each of up to 10,000 parts may be 5 GB and the completed object may reach 5 TB. If you sign UploadPart URLs, you must count parts yourself: charge the reservation for partSize × expectedParts at initiation, refuse CompleteMultipartUpload when the summed part sizes exceed the ceiling, and set a lifecycle rule to abort incomplete uploads — unfinished parts are billed while they sit there, invisible in the object listing. The broader trade-offs live in direct-to-cloud upload patterns.

A 429 without jitter creates a synchronised stampede

Retry-After: 30 sent to 400 clients at once produces 400 simultaneous retries 30 seconds later, and the second wave is worse than the first because it is perfectly aligned. Add server-side jitter to the header — Retry-After: 30 + random(0, 15) — and expect clients to add their own. Client-side, the decorrelated-jitter algorithm in implementing exponential backoff for failed chunks is the pattern to standardise on. Also distinguish 429 (slow down, the request was fine) from 403 (the credential is dead, get a new one); clients that retry a 403 with the same URL loop forever.

Deciding whether to fail open or closed

When Redis is unreachable, a limiter must choose: allow everything, or deny everything. Denying takes your upload feature down for a cache outage. Allowing removes the rate control at the worst moment. The workable answer is to fail open on the request budget and closed on the byte budget: issuance keeps working because the POST policy still caps each object at 100 MB and the Postgres CHECK constraint still caps the tenant’s day, so the blast radius of a Redis outage is bounded even with the token bucket switched off. Add a small per-process in-memory bucket as a backstop — imprecise across instances, but it turns an unbounded flood into a bounded one.

Reservation leaks quietly starve real users

Every reservation that is never settled or released is quota a tenant paid for and did not use. The usual causes are an S3 event notification that was never wired to the queue, a consumer that crashes before settleQuota, and a sweeper that only runs on one instance and dies with it. Alert on SELECT count(*) FROM upload_reservation WHERE state = 'reserved' AND expires_at < now() exceeding zero for more than two sweep intervals — that single query catches all three.

Anti-virus and processing costs are part of the budget

If each accepted object triggers a scan, remember that the scanner is a downstream resource with its own limits. A tenant uploading 10,000 small files inside the byte quota can still saturate a ClamAV pool sized for 50 concurrent scans. Rate-limit the queue consumer independently, and route anything suspicious out of the hot path as described in automated virus scanning integration.

Verification

Prove the issuance limiter refuses. Twenty allowed, the rest refused, with a usable header:

for i in $(seq 1 25); do
  curl -s -o /dev/null -w '%{http_code} ' \
    -X POST https://api.example.com/api/uploads/presign \
    -H "authorization: Bearer $TOKEN" \
    -H 'content-type: application/json' \
    -d '{"contentType":"image/jpeg","declaredBytes":204800}'
done
echo
curl -s -D - -o /dev/null -X POST https://api.example.com/api/uploads/presign \
  -H "authorization: Bearer $TOKEN" -H 'content-type: application/json' \
  -d '{"contentType":"image/jpeg","declaredBytes":204800}' | grep -Ei 'HTTP/|ratelimit|retry-after'
200 200 200 200 200 200 200 200 200 200 200 200 200 200 200 200 200 200 200 200 429 429 429 429 429
HTTP/2 429
ratelimit-limit: 20
ratelimit-remaining: 0
ratelimit-reset: 2
retry-after: 2

Prove the size ceiling binds by attacking your own policy with curl, bypassing every line of frontend code. Build a file larger than maxBytes, then submit the signed form with file last:

head -c 157286400 /dev/urandom > oversize.bin   # 150 MB against a 100 MB cap

curl -s -X POST "$POST_URL" \
  -F "key=$KEY" \
  -F "x-amz-meta-tenant=$TENANT" \
  -F "x-amz-server-side-encryption=AES256" \
  -F "x-amz-algorithm=AWS4-HMAC-SHA256" \
  -F "x-amz-credential=$CREDENTIAL" \
  -F "x-amz-date=$AMZDATE" \
  -F "policy=$POLICY_B64" \
  -F "x-amz-signature=$SIGNATURE" \
  -F "Content-Type=image/jpeg" \
  -F "file=@oversize.bin"
<?xml version="1.0" encoding="UTF-8"?>
<Error>
  <Code>EntityTooLarge</Code>
  <Message>Your proposed upload exceeds the maximum allowed size</Message>
  <ProposedSize>157286400</ProposedSize>
  <MaxSizeAllowed>104857600</MaxSizeAllowed>
  <RequestId>8ZR3H2QK9WBTX1CD</RequestId>
</Error>

Two more assertions worth automating. Swap the metadata field to another tenant’s ID and confirm S3 answers AccessDenied with Invalid according to Policy: Policy Condition failed: ["eq", "$x-amz-meta-tenant", "t_1042"]. Then run the quota path directly against Postgres:

psql "$DATABASE_URL" -c \
  "UPDATE tenant_usage SET used_bytes = limit_bytes WHERE tenant_id = 't_1042';"
curl -s -o /dev/null -w '%{http_code}\n' -X POST https://api.example.com/api/uploads/presign \
  -H "authorization: Bearer $TOKEN" -H 'content-type: application/json' \
  -d '{"contentType":"image/jpeg","declaredBytes":204800}'
# 429  — raised by the usage_within_plan CHECK constraint, not by application logic

Finally, confirm the sweeper actually returns quota: issue a ticket, never upload, and watch used_bytes fall back to its previous value within reservationTtlSeconds + 60.

Frequently Asked Questions

Should I rate limit by IP address or by user account?

By account wherever a request is authenticated — it is the identity that maps to your billing and to the abuser. Keep an IP bucket as a secondary circuit-breaker set roughly ten times looser, key IPv6 on the /64 prefix rather than the full address, and never make IP your only control for an authenticated endpoint.

Can a presigned PUT enforce a maximum size?

Only as an exact value. If you sign ContentLength into the PutObjectCommand, S3 requires that precise byte count and rejects anything else with SignatureDoesNotMatch, which clients cannot act on sensibly. A range needs a POST policy content-length-range condition, which is why S3 presigned URL workflows recommends POST for browser-facing uploads with variable file sizes.

What happens to reserved quota when an upload fails halfway?

Nothing arrives in the bucket, so no ObjectCreated event fires and the reservation stays in reserved state until expires_at passes, at which point the sweeper releases the bytes. That is why the TTL must comfortably exceed your slowest realistic upload — 15 minutes covers a 100 MB file on a poor mobile connection with retries.

Is edge rate limiting on its own enough?

No. The edge sees IP addresses and paths, not tenant plans, byte budgets, or which of your customers is on a trial. It is excellent at absorbing volumetric floods for free and useless at answering “has this account used its 2 GB today?” Run both, and keep the byte budget at the origin where the identity lives.

How do I let a legitimate customer burst without raising everyone’s limit?

Raise capacity rather than refillPerSecond for that plan. Capacity governs how much idle allowance accumulates, so a larger bucket absorbs a 200-file drag-and-drop without changing the sustained rate an attacker could hold. Cap it below the daily byte quota so the burst still cannot exhaust a day’s allowance in one minute.