This is the source-of-truth document for how the nzimageapi stack decides who may call it and
how it stops any caller — approved or not — from running up processing time or cost. Read this
before onboarding a new consumer, before tuning any limit, and before responding to a cost or
abuse alert.
Governing constraint: this project is meant to run entirely within AWS's perpetual free tiers
(see docs/adr/0001-free-per-consumer-secrets.md for why).
Every control described here was chosen because it is free. Nothing in this document costs money
to operate at the traffic levels this project expects.
Two goals, in this order:
- Only approved sources can call
/image. Software the owner hasn't explicitly approved must not be able to get a useful response. - No caller — approved or not — can run up processing time or cost. Even a legitimate, approved consumer that misbehaves (bug, retry storm, leaked credential) must be bounded.
A hard constraint shapes the design: the whole thing must cost $0, using only AWS's perpetual free tiers. That constraint ruled out the strongest available design (Amazon Cognito machine-to-machine authentication, which has no free tier) in favor of a weaker but genuinely free one: per-consumer static secrets checked inside the Lambda, backed by free-tier cost ceilings (stage throttling, the account-wide Lambda concurrency limit, alarms, a budget). The trade-offs this accepts are listed in §3 and in the ADR.
Browser / mobile app ──HTTPS──▶ Consumer's OWN backend/edge ──secret header──▶ /image (HTTP API)
(public client: (Next.js route / CF Worker / │ in-Lambda check vs
holds NO secret) small server; holds THIS │ API_CLIENT_SECRETS;
▲ consumer's random secret) │ 401 before any work
│ same-origin JSON │
└─────────────────────────────────────────────────────────┘
Converter images: the browser loads the HMAC-signed converter URL directly (unchanged; already
gated independently — see §10).
There are two separate request paths, and they're gated differently:
- Image metadata (
GET /image) — returns JSON describing a randomly chosen image (title, description, thumbnail/large-image URLs). Gated by the per-consumer secret (§4). This is the path a consumer's backend calls. - Image bytes, for collections that need format conversion (JP2/TIFF → JPEG) — the metadata
response contains a URL pointing at
Jp2ConverterFunction's public Function URL. That URL is itself HMAC-signed by the Swift Lambda (unrelated toAPI_CLIENT_SECRETS) and is meant to be loaded directly by the end user's browser, not proxied. See §10.
Why the browser/app never holds a secret: anything shipped to a browser page (view-source) or
a mobile app binary (decompilation) is effectively public. A static secret embedded in either is
not secret. So every consumer must run some backend or edge component — even a few lines in a
serverless function — that holds its API_CLIENT_SECRETS entry and is the only thing that ever
sends the secret header. See §7 for reference implementations.
Because each consumer's proxy calls /image same-origin from its own backend, and the browser
only ever talks to that proxy, no CORS configuration is needed on /image (and none exists).
Defended against:
- An unapproved caller (random internet traffic, a scraper, a copycat site) getting a useful
response from
/image— blocked by the secret check (§4). - Unbounded cost from any caller, approved or not — bounded by stage throttling + reserved concurrency on both Lambdas (§8).
- Decompression bombs and SSRF via the converter — the converter's existing HMAC signature, host allowlist, and pixel/size/time ceilings, unchanged by this work (§10).
- A single leaked/compromised consumer secret — revocable independently of other consumers (§6), and bounded in the meantime by the same throttling/concurrency ceilings.
Explicitly out of scope, and why:
- DDoS / volumetric attack mitigation. AWS WAF is not available on an HTTP API (API Gateway v2) without fronting it with CloudFront, which is out of scope for a $0 design. Stage throttling provides a cost ceiling but not attack absorption.
- A fully-public, direct-from-browser path with real access control. Impossible by construction — a browser cannot keep a secret. If a truly public (unauthenticated) read path is ever wanted, that's a deliberate product decision, not a gap in this design.
- Long-lived-secret compromise detection. There's no automatic anomaly detection on secret misuse beyond the invocation-count alarms (§8/§9), which are coarse (hourly sums, not per-secret).
Format. One environment variable, API_CLIENT_SECRETS, holds a comma-separated list of
name:secret pairs — one per approved consumer:
API_CLIENT_SECRETS=site:3f9a...,mobile:7bc1...
Generation. Each secret is a random hex string:
openssl rand -hex 32Hex-only is deliberate — the format uses : to separate name from secret and , to separate
entries, so neither character may appear inside a name or secret.
Check. On every request, Sources/NZImageApiLambda/NZImageApiLambda.swift:
- Reads
API_CLIENT_SECRETS. If unset or empty, fails closed with500(misconfiguration, not silently open). - Reads the request's
secretheader. If absent,401. - Calls
NZImageApiLambda.authorizedConsumer(presented:allowed:), which iterates every configured entry and constant-time-compares the presented value against each entry's secret — it does not return early on the first match, so response timing can't reveal which or how many entries matched. - On a match, logs
Authorized consumer: <name>(CloudWatch Logs — see §9) and proceeds. No match →401.
Storage. The pairs live in the ApiClientSecrets CloudFormation parameter (NoEcho: true,
supplied via --parameter-overrides or the gitignored samconfig.toml — never committed), and
land in the Lambda as the API_CLIENT_SECRETS environment variable. An optional upgrade (still
free) is to store the pairs in an SSM Parameter Store SecureString and read it at cold start
instead — that makes onboarding/revoking a consumer an SSM edit with no redeploy. Not implemented
by default; the env var is simpler and matches the project's existing NoEcho-parameter pattern
(DigitalNzApiKey, ConverterSigningKey).
Deliberate trade-offs vs. Cognito M2M (the design this replaced — see the ADR):
- Secrets are long-lived and rotated manually, not auto-expiring 1-hour OAuth tokens.
- The check happens inside the Lambda, not at the API Gateway edge — an unauthorized request
still invokes the Swift Lambda briefly (a few milliseconds; the check is the very first thing
handledoes, before any DigitalNZ API call) rather than being rejected before invocation. Bounded by the throttling/concurrency ceilings in §8. - No standard OAuth scopes, token introspection, or centralized revocation UI — just an env var and a redeploy. Fine at the current scale (~2 consumers); revisit if that stops being true (§9, ADR "triggers to revisit").
- Generate a secret:
openssl rand -hex 32. - Append
,<name>:<secret>to theApiClientSecretsvalue insamconfig.toml(or your--parameter-overrides). Choose a short, stablename— it's what shows up in logs. - Deploy:
sam build && sam deploy(or, for a config-only change with no code change,aws lambda update-function-configuration --function-name <NZImageApiFunction physical name> --environment "Variables={...,API_CLIENT_SECRETS=<new full value>,...}"— but note this bypasses CloudFormation and will drift from the template until the nextsam deploy; prefer a full deploy unless you need the change immediately). - Hand the secret to the consumer over a channel that isn't this repo or a public issue tracker (e.g. a password manager share, an encrypted message). It must be stored server-side only by the consumer (§7) — never in client-side/browser/app code.
- Smoke-test: have the consumer's proxy make one real request and confirm
200. Check CloudWatch Logs forAuthorized consumer: <name>to confirm it's being attributed correctly (§9), or run:which assertsAPI_URL="https://<api-id>.execute-api.<region>.amazonaws.com" \ API_SECRET="<the new secret>" \ ./scripts/smoke-test-access-control.sh
200with the valid secret and401for missing/wrong secrets. Pass--throttleto also confirm the stage throttle engages (fires ~30 rapid requests — real invocations, so it's opt-in, not run by default).
Revoke (consumer no longer approved, or secret suspected leaked):
- Remove that
name:secretentry fromApiClientSecrets. - Deploy.
- Verify: a request using the removed secret now returns
401.
Rotate (planned, no compromise):
- Generate a new secret for that consumer.
- Replace the entry (same
name, new secret) inApiClientSecrets. - Deploy.
- Hand the new secret to the consumer; have them switch over.
- Old secret stops working the moment the deploy completes — there is no overlap window, so coordinate the consumer's switchover with the deploy if it must be zero-downtime for them (e.g. deploy first with both old and new entries present temporarily, confirm the consumer has switched, then deploy again removing the old entry).
Emergency rotation under suspected compromise:
- Immediately remove the compromised entry from
ApiClientSecretsand deploy — this is the fast path to cut off the leaked secret, even before the replacement is ready. - Generate and distribute a new secret to the legitimate consumer per §5.
- Check CloudWatch Logs / the invocation alarms (§9) around the suspected leak window to gauge
impact (how many requests, over what period, attributed to that consumer's
name). - If cost impact is a concern, check the
nzimageapi-monthly-cost-tripwirebudget (§8) — it would have already emailed if actual/forecasted spend crossed $1.
Every consumer runs a thin backend/edge component that holds its secret and is the only thing
that ever calls /image directly. The browser or app talks only to that component.
// Server-side only (Next.js app/api/random-image/route.ts, or a Cloudflare Worker fetch handler).
// The browser calls THIS endpoint, same-origin — never /image directly.
export async function GET() {
const res = await fetch(`${process.env.IMAGE_API_URL}/image`, {
headers: { secret: process.env.NZIMAGE_API_SECRET }, // this consumer's API_CLIENT_SECRETS entry
});
return new Response(await res.text(), {
status: res.status,
headers: { "content-type": "application/json" },
});
}IMAGE_API_URL and NZIMAGE_API_SECRET are server-side environment variables in the consumer's
own deployment (e.g. Vercel/Cloudflare project settings) — never sent to the client.
Same shape: app → your backend → /image, with the backend holding the secret. The extra
question a mobile app raises is trusting the app → backend hop, since the app binary can't
hold a secret either (it can be decompiled). Options, in order of recommendation for a
login-free public app:
- App attestation — iOS App Attest
/ Android Play Integrity. The
app proves to your backend it's a genuine, unmodified instance on a real device; the backend
verifies with Apple/Google before proxying to
/image. - User login, if/when the app grows accounts — a per-user session token instead of a shared app-wide credential.
- Minimal — rate-limit the backend endpoint and accept it's effectively public. Weakest option; doesn't fully meet "approved sources only." Optionally add TLS certificate pinning in the app as a defense against a MitM-modified client either way.
Every control below is free at this project's scale. Each caps a different failure mode.
| Control | What it caps | Current value | Where |
|---|---|---|---|
| Stage throttling | Request rate at the API Gateway edge | ThrottlingRateLimit: 10/s, ThrottlingBurstLimit: 20 |
template.yaml → ServerlessHttpApi.Properties.DefaultRouteSettings |
| Account-wide Lambda concurrency | Parallel executions across both functions combined | 10 (this AWS account's total quota — see below) |
AWS account-level Service Quota, not this template |
| CloudWatch alarm (API) | Notifies on unexpected volume | >200 invocations/hour → email | NZImageApiInvocationsAlarm |
| CloudWatch alarm (converter) | Notifies on unexpected volume | >100 invocations/hour → email | Jp2ConverterInvocationsAlarm |
| Monthly budget | Notifies on any real spend | >$1 actual or forecasted → email | MonthlyCostBudget |
Reserved concurrency was planned but isn't set. This AWS account's total Lambda concurrent-
executions quota is 10 (not the usual 1,000 default), and is already fully unreserved. Setting
ReservedConcurrentExecutions on either function — even a small value — would push the account's
unreserved pool below its own floor and the deploy is rejected (InvalidRequest:
"decreases account's UnreservedConcurrentExecution below its minimum value of [10]"). In practice
this account-wide ceiling is already a tighter concurrency cap than the per-function
reservations in the original design (10 total vs. the planned 20+5=25), so nothing is lost today —
but it also means the two functions share one pool rather than having independent caps: a burst on
one can starve the other. If the quota is ever increased (aws service-quotas request-service-quota-increase --service-code lambda --quota-code L-B99A9384 --desired-value <N> --region ap-southeast-2 — free to request; AWS approval is not instant), reintroduce
ReservedConcurrentExecutions on both functions (comments left in template.yaml marking where)
to subdivide the larger pool and restore independent per-function ceilings.
Tuning: raise the throttling numbers if legitimate traffic grows past them (watch for 429s
in that case — see §9); raise the alarm thresholds if they start firing on normal traffic.
Verified free-tier facts this design relies on (re-verify at aws.amazon.com if tuning years after this was written):
- Lambda: perpetual free tier, 1M requests + 400,000 GB-seconds/month, every account, never expires.
- Reserved concurrency: no charge to configure.
- API Gateway HTTP API stage throttling: configuration only, no charge.
- CloudWatch alarms: first 10 per account are free (this stack uses 2).
- AWS Budgets: basic cost budgets with email notifications are free.
- SSM Parameter Store (if the optional secret-storage upgrade in §4 is adopted): standard parameters at standard throughput are free.
One honest, unavoidable caveat: the API Gateway HTTP API free tier (1,000,000 requests/month) lasts only 12 months from account/API creation; after that, requests cost ~$1/million (Lambda compute itself stays free forever). At hobby traffic this is pennies/month, not a real cost concern — but it is not literally $0 forever.
Truly-$0-forever alternative (not implemented, noted for the future): migrate /image off
API Gateway onto a Lambda Function URL, the same mechanism Jp2ConverterFunction already
uses. Function URLs have no per-request charge — only Lambda compute, which is free forever — so
the 12-month cliff disappears entirely. The handler barely changes (same
APIGatewayV2Request/Response payload shape). Trade-off: Function URLs cannot do API-Gateway-style
request-rate throttling, so reserved concurrency would become the sole rate/cost cap (the
API_CLIENT_SECRETS check is unaffected either way). Revisit this if staying literally $0 past the
12-month window becomes a firm requirement.
You get an alarm or budget email. What do you do?
- Identify which fired — the subject line names the alarm (
nzimageapi-api-invocations-high,nzimageapi-converter-invocations-high) or the budget (nzimageapi-monthly-cost-tripwire). - Check who's calling — CloudWatch Logs for
NZImageApiFunction: filter forAuthorized consumer:to see which consumer's secret is driving the volume. A flood of401s with noAuthorized consumerline means unapproved traffic being correctly rejected (not a breach, but worth noting if the volume itself is concerning enough to also want IP-level blocking, which is out of scope — see §3). - If it's an approved consumer misbehaving (bug, retry loop): contact them; consider temporarily lowering their impact isn't possible per-consumer today (the throttle/concurrency caps are stack-wide, not per-secret) — the blunt option is to revoke (§6) until they fix it.
- If it's unapproved traffic: confirm it's actually being rejected (
401s in the logs, not200s). The throttling ceiling and the account-wide concurrency limit already bound the cost impact; no further action is required unless volume is high enough to be a nuisance, in which case consider the CloudFront + WAF escalation path noted in the ADR. - If the budget alarm fired (real spend, not just volume): check the AWS Billing console's cost breakdown by service. Given the free-tier ceilings in §8, the most likely causes are (a) the 12-month API Gateway free-tier window has lapsed (§8 caveat) or (b) a reserved-concurrency or throttling value was raised high enough to be pushing real usage past the 1M-request Lambda free tier — check actual invocation counts against the 1,000,000/month and 400,000 GB-second ceilings.
Reading 429s: a 429 from /image means the stage throttle rejected the request (§8). If
these show up for legitimate traffic, raise ThrottlingRateLimit/ThrottlingBurstLimit and
redeploy.
Jp2ConverterFunction (converter/app.py) is a public, unauthenticated Function URL
(AuthType: NONE) — but "unauthenticated" only means AWS doesn't gate it; the application layer
does:
- HMAC signature. Every converter URL the Swift Lambda emits is signed with
CONVERTER_SIGNING_KEY(shared,NoEchoparameter). The converter recomputes the signature over the presentedurlparameter and rejects (403) anything that doesn't match (hmac.compare_digest). Only the Swift Lambda can mint a valid URL — a browser loading a Swift-Lambda-issued link works; anyone hand-crafting a converter URL does not. - Host allowlist.
ALLOWED_HOSTSrestricts which origin the converter will fetch a master image from (NDHA, Feilding Library, Manawatū Heritage), bounding SSRF exposure even if the signature check were ever bypassed. - Resource ceilings.
MAX_IMAGE_PIXELS = 500_000_000(decompression-bomb guard), a download size cap, and a download timeout bound worst-case memory/time per invocation. Combined with the account-wide Lambda concurrency limit (§8), this bounds worst-case concurrent cost.Cache-Control: public, max-age=31536000, immutableon successful responses means a repeat request for the same image is served from cache (by the browser or any CDN in front) rather than re-invoking the converter at all — the main lever against repeat-recompute cost. - No
API_CLIENT_SECRETSinvolvement. The converter is not part of the per-consumer-secret scheme in §4 — it's called by the end user's browser with a URL the Swift Lambda already authorized, not by a consumer's backend.