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)

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