API keys for backend authentication create a fundamental challenge in web applications: the credentials need to authenticate requests but cannot be exposed to clients. Hardcoding keys in frontend bundles or environment variables leaves them visible in browser developer tools. Anyone who opens the network tab can extract the key and make unauthorized requests. Static configuration files require manual rotation and create operational risk—when a key needs to change, someone has to remember to update it everywhere.
This implementation solves these problems by injecting API keys at the edge using Cloudflare Workers and managing them centrally in AWS Secrets Manager with automatic rotation. The browser makes API requests without any authentication headers. The Cloudflare Worker intercepts these requests, adds the API key, and forwards them to the backend. The system rotates keys every 90 days, syncs them to Cloudflare automatically, and supports zero-downtime rotation through a dual-key architecture.
Architecture
graph TD
User[User Browser] --> CF[Cloudflare Worker]
CF --> |Static Assets| S3[S3 Bucket]
CF --> |API Calls + Key Injection| API[API Gateway]
API --> AUTH[Authorizer Lambda]
AUTH --> |Read Key| SM[AWS Secrets Manager]
SM --> |Rotate Every 90 Days| RL[Rotation Lambda]
RL --> |Sync API_KEY| CF
The Cloudflare Worker sits between the user's browser and the backend infrastructure. When a request arrives, the Worker examines the path to determine how to handle it. Static assets like JavaScript bundles, CSS files, and images pass through to S3 for serving. API requests take a different path—the Worker adds the API key to the x-api-key header before forwarding to API Gateway. The user's browser never sees the key because it never appears in any response or client-side code.
On the AWS side, Secrets Manager stores the API key and handles rotation scheduling. When the 90-day rotation interval expires, Secrets Manager invokes a Lambda function that generates a new key, syncs it to the Cloudflare Worker via the Cloudflare API, and promotes the new version. The API Gateway authorizer Lambda reads the current key from Secrets Manager to validate each incoming request. This creates a closed loop where key management happens entirely within the infrastructure, invisible to both users and frontend code.
Dual-Key Architecture
The secret structure supports zero-downtime rotation by maintaining two valid keys simultaneously:
{
"currentKey": "a1b2c3d4e5f6...",
"previousKey": "x7y8z9a0b1c2..."
}
When rotation occurs, the new key becomes currentKey and the old key moves to previousKey. The authorizer accepts both keys during this transition period. This design eliminates the timing window that plagues simpler rotation schemes—there's no moment where the Cloudflare Worker has a new key but the authorizer hasn't learned about it yet, or vice versa.
After the next rotation cycle, previousKey gets overwritten with what was currentKey. Each key remains valid for up to 180 days total: 90 days as the current key, then 90 more days as the previous key while the next rotation completes. This generous overlap window means even significant delays in rotation propagation won't cause authentication failures.
Secrets Manager Configuration
The CDK construct creates the secret with an initial random key:
const apiKeySecret = new secretsmanager.Secret(this, 'ApiKeySecret', {
secretName: `my-app/${environment}/api-key`,
description: `API key for ${environment} environment`,
generateSecretString: {
excludePunctuation: true,
includeSpace: false,
passwordLength: 32,
secretStringTemplate: JSON.stringify({ previousKey: '' }),
generateStringKey: 'currentKey',
},
});
The generateSecretString configuration deserves attention. By providing a secretStringTemplate with previousKey already present and setting generateStringKey to currentKey, Secrets Manager creates the dual-key JSON structure on first deployment. The initial previousKey is empty, which the authorizer handles gracefully by only checking it when non-empty.
Each environment gets its own secret with independent rotation schedules. Development keys can rotate more frequently for testing without affecting production. The secret names follow a hierarchical pattern that makes IAM policies and CLI queries straightforward.
Rotation Scheduling
Secrets Manager handles rotation scheduling internally without requiring EventBridge or CloudWatch Events. The CDK configures the schedule when attaching the rotation Lambda to the secret:
apiKeySecret.addRotationSchedule('RotationSchedule', {
rotationLambda: rotationLambda,
automaticallyAfter: Duration.days(90),
});
Secrets Manager tracks the last rotation date and invokes the Lambda when the 90-day interval expires. The first rotation occurs 90 days after the secret is created. This built-in scheduling simplifies the architecture by eliminating the need for separate timer infrastructure. You can verify the schedule and next rotation date through the AWS console or CLI:
aws secretsmanager describe-secret --secret-id my-app/development/api-key \
--query '{LastRotated:LastRotatedDate,NextRotation:NextRotationDate}'
Rotation Lambda
The rotation Lambda implements AWS Secrets Manager's four-step rotation protocol. Secrets Manager invokes the same Lambda four times in sequence, passing a different Step value each time. This design allows the rotation process to be interrupted and resumed, and provides clear checkpoints for debugging when things go wrong.
export const handler = async (event: RotationEvent): Promise<void> => {
const { SecretId, ClientRequestToken, Step } = event;
switch (Step) {
case 'createSecret':
await createSecret(SecretId, ClientRequestToken);
break;
case 'setSecret':
await setSecret(SecretId, ClientRequestToken);
break;
case 'testSecret':
await testSecret(SecretId, ClientRequestToken);
break;
case 'finishSecret':
await finishSecret(SecretId, ClientRequestToken);
break;
}
};
createSecret
The first step generates the new credentials without making them active. The Lambda creates a new 32-character hex key using cryptographically secure random bytes and stores it as a pending version in Secrets Manager. Critically, it also preserves the current key as the previous key in the new secret value:
async function createSecret(secretId: string, token: string) {
const current = await getSecretValue(secretId, 'AWSCURRENT');
const newKey = randomBytes(16).toString('hex');
await putSecretValue(secretId, token, {
currentKey: newKey,
previousKey: current.currentKey,
});
}
The ClientRequestToken parameter acts as an idempotency key. If this step runs twice with the same token, the second invocation detects the existing pending version and skips regenerating the key. This prevents issues if the Lambda times out and Secrets Manager retries.
setSecret
The second step propagates the new credentials to external systems—in this case, the Cloudflare Worker. The Lambda fetches Cloudflare API credentials from a separate secret, retrieves the pending key value, and calls the Cloudflare Workers Secrets API to update the Worker's secret:
async function setSecret(secretId: string, token: string) {
const cfCreds = await getCloudflareCredentials();
const pending = await getSecretValue(secretId, 'AWSPENDING', token);
const scriptName = `my-worker-${environment}`;
await fetch(
`https://api.cloudflare.com/client/v4/accounts/${cfCreds.accountId}/workers/scripts/${scriptName}/secrets`,
{
method: 'PUT',
headers: {
'Authorization': `Bearer ${cfCreds.apiToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
name: 'API_KEY',
text: pending.currentKey,
type: 'secret_text',
}),
},
);
}
This is where the dual-key architecture pays off. Even though the Cloudflare Worker now has the new key, the authorizer still accepts the old key as previousKey. Requests in flight during the sync continue to work.
testSecret and finishSecret
The testSecret step validates that the secret structure is correct and the new credentials work. For API keys, this means checking that currentKey exists and has sufficient length. More sophisticated implementations might make a test API call using the new credentials.
The finishSecret step promotes the pending version to current by updating the version stage labels in Secrets Manager. After this step completes, the new key becomes AWSCURRENT and the old version loses that label. The authorizer's next cache refresh picks up the new values.
Authorizer Lambda
The API Gateway authorizer validates incoming requests by comparing the provided API key against the keys stored in Secrets Manager. The comparison uses Node's timingSafeEqual function to prevent timing attacks—a class of vulnerability where attackers can infer correct characters by measuring response times. Cloudflare has a good example of this pattern in their Workers documentation:
import { timingSafeEqual } from 'crypto';
function validateApiKey(providedKey: string, secret: SecretValue): boolean {
const providedBuffer = Buffer.from(providedKey);
// Check current key
const currentBuffer = Buffer.from(secret.currentKey);
if (providedBuffer.length === currentBuffer.length) {
if (timingSafeEqual(providedBuffer, currentBuffer)) {
return true;
}
}
// Check previous key for zero-downtime rotation
if (secret.previousKey) {
const previousBuffer = Buffer.from(secret.previousKey);
if (providedBuffer.length === previousBuffer.length) {
if (timingSafeEqual(providedBuffer, previousBuffer)) {
return true;
}
}
}
return false;
}
Regular string comparison (===) short-circuits on the first mismatched character, returning faster for keys that differ early. An attacker making thousands of requests could statistically determine correct characters by measuring response latencies. The timingSafeEqual function compares all bytes in constant time regardless of where differences occur, eliminating this information leak.
The authorizer also redacts API keys from logs to prevent accidental exposure in CloudWatch. Every log statement that might include request data replaces the key value with [REDACTED].
Cloudflare Worker
The Worker configuration lives in wrangler.toml, which defines environment-specific settings for development and production deployments:
name = "my-worker"
main = "worker.js"
compatibility_date = "2024-01-01"
[env.development]
vars = { ALLOWED_DOMAIN = "dev.example.com", API_STAGE = "development" }
workers_dev = false
[[env.development.routes]]
pattern = "dev.example.com/*"
zone_name = "example.com"
[env.production]
vars = { ALLOWED_DOMAIN = "example.com", API_STAGE = "production" }
workers_dev = false
[[env.production.routes]]
pattern = "example.com/*"
zone_name = "example.com"
The configuration separates public variables from secrets. Environment variables like ALLOWED_DOMAIN and API_STAGE are non-sensitive and can live in the config file. Secrets like API_KEY, API_GATEWAY_HOST, and CLERK_SECRET_KEY are set separately using wrangler secret put or through the Cloudflare API during rotation. This separation means the wrangler.toml can be committed to version control without exposing credentials.
Each environment gets its own route configuration. The workers_dev = false setting disables the default *.workers.dev subdomain, ensuring all traffic goes through the custom domain where Cloudflare's security features apply.
The Worker handles three responsibilities: origin validation, JWT verification, and API key injection. Each check must pass before the request proceeds to the next stage:
import { verifyToken } from '@clerk/backend';
async function verifyClerkToken(request, env) {
const authHeader = request.headers.get('Authorization');
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return { verified: false, error: 'Missing Authorization header' };
}
const token = authHeader.substring(7);
try {
const claims = await verifyToken(token, {
secretKey: env.CLERK_SECRET_KEY,
authorizedParties: [`https://${env.ALLOWED_DOMAIN}`],
});
return { verified: true, claims };
} catch (error) {
return { verified: false, error: error.message };
}
}
export default {
async fetch(request, env) {
const url = new URL(request.url);
if (url.pathname.startsWith('/api/')) {
// Validate origin
const origin = request.headers.get('origin');
const allowedOrigins = [
`https://${env.ALLOWED_DOMAIN}`,
'http://localhost:5173',
];
if (origin && !allowedOrigins.includes(origin)) {
return new Response('Forbidden', { status: 403 });
}
// Verify JWT
const { verified, error } = await verifyClerkToken(request, env);
if (!verified) {
return new Response(
JSON.stringify({ error: 'Unauthorized', message: error }),
{ status: 401, headers: { 'Content-Type': 'application/json' } },
);
}
// Inject API key and forward
const headers = new Headers(request.headers);
headers.set('x-api-key', env.API_KEY);
headers.delete('authorization'); // Don't forward to backend
const apiUrl = `https://${env.API_GATEWAY_HOST}/${env.API_STAGE}${url.pathname}`;
return fetch(apiUrl, {
method: request.method,
headers,
body: request.body,
});
}
return handleStaticAssets(request, env);
},
};
The Worker uses Clerk's @clerk/backend package for JWT verification. The authorizedParties setting ensures tokens were issued for this specific domain, preventing tokens from other Clerk applications from being accepted. Local development origins are included for testing.
When rotation occurs, the API_KEY secret gets updated through the Cloudflare API. No Worker redeployment is needed—the next request automatically uses the new key. This separation between code deployment and secret updates means routine key rotation doesn't require CI/CD pipelines or deployment approvals.
GitHub Actions Integration
The deployment workflow fetches Cloudflare credentials from Secrets Manager rather than storing them in GitHub Secrets. This keeps AWS Secrets Manager as the single source of truth for all sensitive values:
- name: Get Cloudflare Credentials from Secrets Manager
id: cloudflare-creds
run: |
CF_CREDS=$(aws secretsmanager get-secret-value \
--secret-id "my-app/cloudflare-credentials" \
--query 'SecretString' \
--output text)
CF_TOKEN=$(echo "$CF_CREDS" | jq -r '.apiToken')
echo "::add-mask::$CF_TOKEN"
echo "CLOUDFLARE_API_TOKEN=$CF_TOKEN" >> $GITHUB_OUTPUT
- name: Deploy Cloudflare Worker
run: npx wrangler deploy --env production
env:
CLOUDFLARE_API_TOKEN: ${{ steps.cloudflare-creds.outputs.CLOUDFLARE_API_TOKEN }}
The add-mask command tells GitHub Actions to redact the token value from all logs. Even if a subsequent step accidentally logs the token, it appears as *** in the output. This defense-in-depth approach means a single logging mistake doesn't expose credentials.
Centralizing secrets in AWS also simplifies access auditing. CloudTrail logs every Secrets Manager access, providing a clear record of which principals accessed which secrets and when. Distributing secrets across GitHub, Cloudflare, and AWS would require correlating logs from multiple systems.
Manual Rotation
Automatic 90-day rotation handles the normal case, but sometimes you need to rotate immediately. A security incident, a suspected leak, or an employee departure might require emergency rotation. A shell script provides quick access:
#!/bin/bash
ENVIRONMENT="${1:-development}"
SECRET_ID="my-app/${ENVIRONMENT}/api-key"
aws secretsmanager rotate-secret --secret-id "$SECRET_ID"
For teams that prefer web interfaces or need approval workflows, a GitHub Actions workflow provides the same capability through the repository's Actions tab:
name: Rotate API Key
on:
workflow_dispatch:
inputs:
environment:
description: 'Environment to rotate'
required: true
type: choice
options:
- development
- production
jobs:
rotate:
runs-on: ubuntu-latest
steps:
- name: Trigger Rotation
run: |
aws secretsmanager rotate-secret \
--secret-id "my-app/${{ inputs.environment }}/api-key"
The workflow uses workflow_dispatch so it only runs when manually triggered. The environment dropdown prevents typos and makes it clear which keys are being rotated. Adding environment protection rules in GitHub provides an additional approval step for production rotations.
Layered Security Model
The API key is one layer in a defense-in-depth approach. As shown in the Worker code above, the Cloudflare Worker enforces multiple checks before forwarding requests to the backend. Origin validation blocks requests from unknown domains. JWT verification ensures users are authenticated through Clerk. Only after both checks pass does the Worker inject the API key and forward the request. The API Gateway authorizer then validates the key using timing-safe comparison.
The API key alone provides no access. An attacker would need both a valid Clerk session and knowledge of the API key. Since the key never reaches the client and requires an authenticated session to trigger its use, this combination is difficult to achieve.
The 90-day automatic rotation reduces risk if keys are somehow exposed. The dual-key architecture ensures zero downtime during rotation by accepting both current and previous keys until the next rotation cycle. API keys are redacted from CloudWatch logs, and CloudTrail captures an audit trail of all Secrets Manager access.