Building a Defense‑in‑Depth Auth Strategy for Public Content APIs (REST)

Java/Spring backend • AWS CloudFront • Apigee X • RS256 JWT with JWKS (Apigee KVM) • Optional API Keys for Partners • Explicit Origin Allowlist via CloudFront Functions

TL;DR

  • Public content still needs protection from scraping and origin overload.
  • Enforce CORS allowlists and preflight handling at the edge with CloudFront Functions.
  • Issue a short‑lived RS256 “proof‑of‑edge” JWT via Lambda@Edge; verify in Apigee using a JWKS URL hosted on Apigee (KVM‑backed).
  • Keep Spring responses cache‑friendly (ETag + Cache‑Control), normalize inputs, and cap pagination.
  • Rotate keys without downtime using kid + JWKS dual‑key publishing.
  • For partners/server flows, layer API keys and quotas in Apigee (do not use API keys in browsers).

Table of Contents

  • Why “auth” for public content APIs?
  • Threat model
  • Reference architecture (REST)
  • Technique catalog: 15 methods and where they live
  • Step‑by‑step implementation
    • CloudFront behaviors, cache, and WAF
    • CloudFront Functions: CORS allowlist + preflight, and ACAO/security headers
    • Lambda@Edge: RS256 “proof‑of‑edge” JWT
    • Apigee: JWKS via KVM and a no‑target proxy
    • Apigee: VerifyJWT, input defenses, and rate limiting
    • Partner/server overlay: API keys + quotas
    • Spring: ETag, Cache‑Control, pagination caps
  • Key rotation playbook
  • Observability and incident response
  • Final checklist

Why “auth” for public content APIs?

“Public” does not mean “unprotected.” Ratings, reviews, and Q&A endpoints attract scraping, cloning, and sudden bursts that can clobber your origin. You’ll get better reliability and lower latency if you:

  • Shift work to the edge with caching, allowlists, and bot controls.
  • Prove requests traversed your edge via a short‑lived, asymmetric JWT.
  • Rate‑limit fairly and block obvious abuse.
  • Keep responses cache‑friendly.

Threat model

  • High‑volume scraping and content cloning
  • Origin bypass (hitting Apigee directly)
  • Cache poisoning via unnormalized queries
  • Availability attacks (spikes or sustained floods)

Goals:

  • Maximize CDN cache hit ratio and minimize origin load.
  • Ensure every request passes edge defenses.
  • Make abuse costly/detectable without harming legit users.
  • Rotate keys with zero downtime.

Reference architecture (REST)

image

Technique catalog: 15 methods and where they live

Use this as a quick-reference section in your blog.

  1. Cache‑friendly API design
    • What: ETag + Cache-Control with short TTLs and stale-while-revalidate.
    • Where: Spring responses.
    • Status: Implemented.
  1. Cache‑key hygiene
    • What: Query param whitelist; no headers/cookies in cache key.
    • Where: CloudFront cache policy; Apigee param allowlist.
    • Status: Implemented.
  1. Origin Shield and regionalization
    • What: Collapse origin bursts and reduce fetches.
    • Where: CloudFront Origin Shield.
    • Status: Implemented.
  1. WAF managed rule sets
    • What: Baseline protections (SQLi/XSS/etc.).
    • Where: AWS WAF CRS.
    • Status: Implemented.
  1. Rate‑based rules and human challenges
    • What: Rate caps + conditional CAPTCHA/Challenge.
    • Where: AWS WAF.
    • Status: Implemented.
  1. Proof‑of‑edge token (RS256 JWT)
    • What: Short‑lived JWT bound to method/path/query.
    • Where: Lambda@Edge adds X‑Edge‑JWT.
    • Status: Implemented.
  1. JWKS hosting + zero‑downtime rotation
    • What: Public keys via JWKS; rotate via kid with overlap.
    • Where: Apigee no‑target proxy + env KVM.
    • Status: Implemented.
  1. Explicit CORS allowlist and preflight at edge
    • What: Enforce allowed Origins; 204 for OPTIONS; echo ACAO per viewer.
    • Where: CloudFront Functions.
    • Status: Implemented.
  1. Origin lockdown
    • What: Keep origin behind edge; optionally allowlist CloudFront IPs.
    • Where: Network policy (e.g., Cloud Armor), routing hygiene.
    • Status: Recommended/optional.
  1. Verified bot handling
    • What: Safer allowances for Googlebot/Bingbot, stricter for unknown.
    • Where: AWS WAF Bot Control labels.
    • Status: Implemented.
  1. Request normalization and parameter allowlists
    • What: Reject unknown params; normalize values.
    • Where: Apigee JS policy; Spring validation.
    • Status: Implemented.
  1. Pagination defenses
    • What: Hard size caps; prefer cursors for deep lists.
    • Where: Spring controllers/service.
    • Status: Implemented.
  1. Partner/server attribution and quotas
    • What: API keys with SpikeArrest + daily Quota.
    • Where: Apigee VerifyAPIKey + policies on partner paths.
    • Status: Implemented.
  1. Observability and incident playbooks
    • What: Metrics/logs + actions (tighten WAF, drop TTLs, quarantine ASNs).
    • Where: CloudFront logs, Apigee analytics, Spring metrics.
    • Status: Implemented/documented.
  1. Device/browser fingerprinting
    • What: Privacy‑safe device ID to enhance rate keys.
    • Where: Client SDK + headers.
    • Status: Out of scope in this blog (future enhancement).

Step‑by‑step implementation

CloudFront behaviors, cache, and WAF

  • Behavior: /api/*
    • Methods: GET, HEAD, OPTIONS; Redirect HTTP→HTTPS
    • Cache policy: default TTL 120s, min 30s, max 300s
    • Query whitelist: page, size, sort, locale
    • Do not vary cache on headers/cookies
  • Origin request policy: forward X‑Forwarded‑For, CloudFront‑Viewer‑ASN, CloudFront‑Viewer‑Country
  • Origin Shield: enable near Apigee region
  • AWS WAF (Web ACL on distribution)
    • Managed: AWSManagedRulesCommonRuleSet + AWSManagedRulesBotControlRuleSet
    • Rate‑based rule on /api/* (e.g., 120 req/60s/IP; tighter for datacenter ASNs)
    • Conditional Challenge/CAPTCHA for elevated risk
    • Start in Count → observe → move to Block/Challenge

CloudFront Functions: CORS allowlist + preflight; ACAO/security headers

Viewer‑request (allowlist + preflight short‑circuit)

function handler(event) {
  var request = event.request;
  var headers = request.headers;

  var ALLOWED = {
    "https://www.example.com": true,
    "https://m.example.com": true
  };

  var origin = headers.origin ? headers.origin.value : undefined;

  if (origin && !ALLOWED[origin]) {
    return {
      statusCode: 403,
      statusDescription: 'Forbidden',
      headers: { 'content-type': { value: 'application/json' } },
      body: JSON.stringify({ error: 'Origin not allowed' })
    };
  }

  if (request.method === 'OPTIONS') {
    var acrm = headers['access-control-request-method']
      ? headers['access-control-request-method'].value
      : '';
    var allowedMethod = acrm && (acrm === 'GET' || acrm === 'HEAD' || acrm === 'OPTIONS');

    if (!origin || !ALLOWED[origin] || !allowedMethod) {
      return { statusCode: 403, statusDescription: 'Forbidden', body: 'CORS preflight not allowed' };
    }

    return {
      statusCode: 204,
      statusDescription: 'No Content',
      headers: {
        'access-control-allow-origin': { value: origin },
        'access-control-allow-methods': { value: 'GET,HEAD,OPTIONS' },
        'access-control-allow-headers': { value: 'Content-Type' },
        'access-control-max-age': { value: '600' },
        'content-length': { value: '0' }
      }
    };
  }

  return request;
}

Viewer‑response (ACAO + security headers)

function handler(event) {
var response = event.response;
var request = event.request;
var headers = response.headers;
var reqHeaders = request.headers;

var ALLOWED = {
"https://www.example.com": true,
"https://m.example.com": true
};

var origin = reqHeaders.origin ? reqHeaders.origin.value : undefined;

headers['x-content-type-options'] = { value: 'nosniff' };
headers['referrer-policy'] = { value: 'strict-origin-when-cross-origin' };
// Optional HSTS when ready:
// headers['strict-transport-security'] = { value: 'max-age=31536000; includeSubDomains; preload' };

if (origin && ALLOWED[origin]) {
headers['access-control-allow-origin'] = { value: origin };
headers['access-control-allow-methods'] = { value: 'GET,HEAD,OPTIONS' };
headers['access-control-allow-headers'] = { value: 'Content-Type' };
headers['vary'] = { value: 'Origin' }; // for downstream caches
}
return response;
}

Lambda@Edge: RS256 “proof‑of‑edge” JWT

Viewer‑request (issue short‑lived RS256 JWT)

const crypto = require('crypto');

const PRIV_KEY_PEM = (process.env.PRIV_KEY_PEM || '').replace(/\\n/g, '\n');
const KID = process.env.KID || 'edge-key-2025-10-01';

const b64u = (buf) => Buffer.from(buf).toString('base64')
.replace(/=/g,'').replace(/\+/g,'-').replace(/\//g,'_');

exports.handler = async (event, ctx, cb) => {
const req = event.Records[0].cf.request;
if (req.method === 'OPTIONS') return cb(null, req); // handled at CF Function

const now = Math.floor(Date.now()/1000);
const header = { alg: 'RS256', typ: 'JWT', kid: KID };
const payload = {
iss: 'cloudfront.edge',
aud: 'apigee-public',
iat: now,
exp: now + 300,
m: req.method,
p: req.uri,
q: req.querystring || ''
};

const h = b64u(JSON.stringify(header));
const p = b64u(JSON.stringify(payload));
const data = `${h}.${p}`;

const signer = crypto.createSign('RSA-SHA256');
signer.update(data).end();
const jwt = `${data}.${b64u(signer.sign(PRIV_KEY_PEM))}`;

req.headers['x-edge-jwt'] = [{ key: 'X-Edge-JWT', value: jwt }];
return cb(null, req);
};

Apigee: JWKS via KVM and a no‑target proxy

KVM entries

  • MapName: edge‑jwks
    • key: jwks → full JWKS JSON (include current + previous during rotation)
    • key: kid → current kid (use as ETag)

KeyValueMapOperations

<KeyValueMapOperations name="KVM-GetJWKS">
<MapName>edge-jwks</MapName>
<Scope>environment</Scope>
<Get assignTo="jwks.json"><Key><Parameter>jwks</Parameter></Key></Get>
<Get assignTo="jwks.kid"><Key><Parameter>kid</Parameter></Key></Get>
</KeyValueMapOperations>

AssignMessage

<AssignMessage name="AM-RespondJWKS">
<AssignTo createNew="true" type="response"/>
<Set>
<Headers>
<Header name="Content-Type">application/json</Header>
<Header name="Cache-Control">public, max-age=300</Header>
<Header name="ETag">{jwks.kid}</Header>
</Headers>
<Payload contentType="application/json">{jwks.json}</Payload>
<StatusCode>200</StatusCode>
</Set>
</AssignMessage>

ProxyEndpoint (no Target)

<ProxyEndpoint name="default">
<PreFlow>
<Request>
<Step><Name>KVM-GetJWKS</Name></Step>
<Step><Name>AM-RespondJWKS</Name></Step>
</Request>
</PreFlow>
<HTTPProxyConnection>
<BasePath>/.well-known</BasePath>
<VirtualHost>default</VirtualHost>
</HTTPProxyConnection>
</ProxyEndpoint>

Apigee: VerifyJWT, input defenses, and rate limiting

VerifyJWT

<VerifyJWT name="VJ-EdgeJWT">
<Algorithm>RS256</Algorithm>
<Source>request.header.X-Edge-JWT</Source>
<JWKS><URI>https://api.example.com/.well-known/edge-jwks.json</URI></JWKS>
<Issuer>cloudfront.edge</Issuer>
<Audience>apigee-public</Audience>
<RequireExpirationTime>true</RequireExpirationTime>
<ClockSkew>60</ClockSkew>
<Claims>
<Claim name="m">{request.verb}</Claim>
<Claim name="p">{proxy.pathsuffix}</Claim>
</Claims>
</VerifyJWT>

Rate key (public)

// JS-BuildRateKeyPublic
var ip = (context.getVariable('request.header.X-Forwarded-For') || '').split(',')[0].trim() || 'na';
var ep = context.getVariable('proxy.pathsuffix') || '/';
context.setVariable('ratelimit.key', ip + ':' + ep);

SpikeArrest

<SpikeArrest name="SA-Public">
<Rate>2ps</Rate>
<Identifier ref="ratelimit.key"/>
</SpikeArrest>

Parameter allowlist

// JS-ValidateParams
var allowed = { productId:1, page:1, size:1, sort:1, locale:1 };
(context.getVariable('request.queryparams.names') || '').split(',').forEach(function(n){
if (n && !allowed[n]) context.setVariable('flow.error', 'unsupported_param');
});

Simple 400

<AssignMessage name="AM-BadRequest">
<AssignTo createNew="true" type="response"/>
<Set>
<StatusCode>400</StatusCode>
<ReasonPhrase>Bad Request</ReasonPhrase>
<Payload contentType="application/json">{"error":"unsupported_param"}</Payload>
</Set>
<IgnoreUnresolvedVariables>true</IgnoreUnresolvedVariables>
</AssignMessage>

Partner/server overlay: API keys + quotas

<VerifyAPIKey name="VAK-Partner">
<APIKey ref="request.header.x-api-key"/>
</VerifyAPIKey>

<SpikeArrest name="SA-Partner">
<Rate>5ps</Rate>
<Identifier ref="verifyapikey.VAK-Partner.apikey"/>
</SpikeArrest>

<Quota name="Q-Partner-Daily">
<Allow count="200000"/><Interval>1</Interval><TimeUnit>day</TimeUnit>
<Identifier ref="verifyapikey.VAK-Partner.apikey"/>
</Quota>

Caching tip:

  • If partner responses are identical, do not vary cache on API key.
  • If they must differ, segment by path (e.g., /api/partner/{partnerId}/resource) for better caching.

Spring: ETag, Cache‑Control, pagination caps

@Bean
public Filter shallowEtagHeaderFilter() { return new ShallowEtagHeaderFilter(); }

@GetMapping("/api/reviews")
public ResponseEntity<ReviewsPage> getReviews(@RequestParam String productId,
@RequestParam(defaultValue="1") int page,
@RequestParam(defaultValue="20") int size) {
size = Math.min(size, 50); // cap
ReviewsPage data = service.fetch(productId, page, size);
return ResponseEntity.ok()
.cacheControl(CacheControl.maxAge(120, TimeUnit.SECONDS)
.staleWhileRevalidate(30, TimeUnit.SECONDS))
.body(data);
}

Key rotation playbook (kid + JWKS)

  • T–10 min: Generate NEW RSA key pair. Update KVM “jwks” to include OLD + NEW; set KVM “kid” to NEW. Invalidate CloudFront for /.well-known/edge-jwks.json.
  • T0: Update Lambda@Edge env (PRIV_KEY_PEM=NEW, KID=NEW). Publish new version and re‑associate behavior.
  • T+10 min: Remove OLD key from JWKS after token exp + skew. Keep offline backup for audit.

Observability and incident response

CloudFront

  • Real‑time logs → Kinesis → S3/Athena
  • Monitor: cache hit ratio, origin fetches, WAF Block/Challenge counts

Apigee

  • Track VerifyJWT/APIKey pass/fail, SpikeArrest/Quota events
  • 2xx/4xx/5xx, 429 per endpoint; hot rate keys and ASNs

Spring

  • Micrometer: TTFB, status distribution, endpoint “hotness”

Playbooks

  • Tighten WAF rate rules and bot sensitivity; enable more Challenges
  • Drop TTLs to 30–60s; rely on stale‑if‑error
  • Clamp page sizes; quarantine abusive ASNs/IP ranges
  • Temporarily raise partner quotas if false positives occur

Final checklist

  • CloudFront: short TTLs; query whitelist; no header/cookie cache variation
  • CloudFront Functions: viewer‑request CORS allowlist + preflight; viewer‑response ACAO + security headers
  • Lambda@Edge: RS256 JWT with kid; 5–10 min exp; bind to method/path/query
  • Apigee: JWKS via KVM; VerifyJWT; parameter allowlist; SpikeArrest/Quota
  • Partner/server: VerifyAPIKey + quotas (overlay on JWT)
  • Spring: ETag/Cache‑Control; pagination caps; input normalization; metrics
  • Rotation: dual‑key JWKS; CloudFront invalidation; Lambda version swap
  • Optional: Cloud Armor allowlisting of CloudFront IP ranges

Appendix: Diagrams

Mermaid (architecture)

flowchart LR
A[Browser/App] -->|GET /api/*| B[CloudFront]
subgraph Edge
B --> CFF1[CF Function: viewer-request\nCORS allowlist + preflight]
CFF1 --> LE[LambdaEdge: viewer-request\nRS256 X-Edge-JWT]
B --> CFF2[CF Function: viewer-response\nACAO + security headers]
end
B -->|Origin| D[Apigee X]
D -->|VerifyJWT JWKS via KVM<br>SpikeArrest/Quota| E[Spring REST]
E --> D --> B --> A

Mermaid (rotation timeline)

sequenceDiagram
participant U as You
participant CF as CloudFront
participant L as Lambda@Edge
participant AX as Apigee JWKS (KVM)

U->>AX: T–10m: Add NEW key (JWKS = OLD + NEW), set kid=NEW
U->>CF: Invalidate /.well-known/edge-jwks.json
U->>L: T0: Update PRIV_KEY_PEM=NEW, KID=NEW<br> publish & associate
Note over CF,AX: Apigee selects key by kid<br> both keys valid during cutover
U->>AX: T+10m: Remove OLD key after token exp + skew

Leave a Comment

Your email address will not be published. Required fields are marked *

Scroll to Top