AWS Secrets Manager vs Cloudflare Secrets Store: khi nào dùng cái nào

AWS Secrets Manager $0.40/secret/mo + auto-rotation Lambda vs Cloudflare Secrets Store free trên Workers Paid. Khi nào chọn cái nào, replication pattern.

· 7 phút đọc

TL;DR

  • AWS Secrets Manager tính phí $0.40/secret/tháng + $0.05/10K API call (pricing). Đắt khi bạn có hàng trăm secret, nhưng có tính năng mà không công cụ khác có: auto-rotation qua Lambda chạy theo schedule, transactional (AWSPENDINGAWSCURRENT), tích hợp native với RDS/Redshift/DocumentDB.
  • Cloudflare Secrets Store miễn phí trên Workers Paid Plan ($5/tháng/account). Account-level, có thể bind vào nhiều Worker — replace cho wrangler secret put per-worker. Nhưng không có rotation logic và immutable: muốn update phải tạo version mới + redeploy.
  • Pair tốt nhất với bài aws-iam-access-key-auto-rotation: IAM access key rotate qua Lambda → ghi vào Secrets Manager → replicate sang Cloudflare Secrets Store qua scheduled Worker.
  • Quy tắc của tôi: regulated workload (PCI/HIPAA/SOC 2) + DB credential → AWS Secrets Manager với rotation policy 30-90 ngày. Worker binding (API key cho upstream, signing key, OIDC private key) → CF Secrets Store. Đừng cố unify trong một store.
  • Custom rotator pattern: Lambda với 4 step createSecretsetSecrettestSecretfinishSecret. Là contract mà Secrets Manager bắt buộc — đừng viết Lambda kiểu “tự nghĩ flow”.
  • Đo cost thực: 50 secret × 30 ngày × 1000 API/ngày = $20 + $7.50 = $27.50/tháng. So với CF Secrets Store: $5 flat. Đó là delta $22.50/tháng — đáng nếu cần rotation, lãng phí nếu chỉ store API key cho Worker.

Vì sao đây không phải so sánh “AWS vs Cloudflare”

Tôi đã thấy nhiều bài đặt câu hỏi như chọn một bên là thắng. Thực tế hai service giải quyết hai vấn đề khác nhau. AWS Secrets Manager là secret lifecycle management — rotation, versioning, replication cross-region, audit qua CloudTrail. Cloudflare Secrets Store là secret distribution — đưa secret vào runtime của Worker mà không phải wrangler secret put cho từng Worker một.

Khi tôi build cloudsecop.net (Astro blog deploy lên Workers, có Worker API gọi Bedrock + send email + verify Turnstile), tôi đụng phải vấn đề secret ở hai nơi:

  1. DB credential cho RDS ở AWS side (nếu có service nội bộ join D1 với RDS qua Hyperdrive)
  2. API key Bedrock, JWT signing key cho OIDC ở Cloudflare side

Lúc đầu tôi đặt tất cả vào Secrets Manager, rồi viết một Worker pull về KV mỗi giờ. Tệ — tăng latency cold start, tốn cost AWS API call, và secret ở KV không có ACL chi tiết. Refactor: AWS Secrets Manager cho DB cred (auto-rotate); CF Secrets Store cho Worker binding (immutable, account-scoped). Hai pool tách biệt.

AWS Secrets Manager — flow rotation thực tế

Phần mạnh nhất của Secrets Manager không phải store, mà rotation. Lambda function phải implement 4 step:

# rotation Lambda — viết theo contract AWS Secrets Manager
import boto3
import json

def lambda_handler(event, context):
    secret_id = event["SecretId"]
    token = event["ClientRequestToken"]
    step = event["Step"]

    client = boto3.client("secretsmanager")

    if step == "createSecret":
        create_secret(client, secret_id, token)
    elif step == "setSecret":
        set_secret(client, secret_id, token)
    elif step == "testSecret":
        test_secret(client, secret_id, token)
    elif step == "finishSecret":
        finish_secret(client, secret_id, token)
    else:
        raise ValueError(f"Invalid step: {step}")

def create_secret(client, secret_id, token):
    # Generate new version, mark AWSPENDING
    current = client.get_secret_value(SecretId=secret_id, VersionStage="AWSCURRENT")
    new_password = client.get_random_password(PasswordLength=32, ExcludeCharacters='/@"')
    new_secret = json.loads(current["SecretString"])
    new_secret["password"] = new_password["RandomPassword"]
    client.put_secret_value(
        SecretId=secret_id,
        ClientRequestToken=token,
        SecretString=json.dumps(new_secret),
        VersionStages=["AWSPENDING"],
    )

def set_secret(client, secret_id, token):
    # Apply AWSPENDING to upstream (RDS, API provider, etc.)
    pending = client.get_secret_value(SecretId=secret_id, VersionStage="AWSPENDING")
    cred = json.loads(pending["SecretString"])
    # e.g. ALTER USER 'app'@'%' IDENTIFIED BY new_password
    apply_to_rds(cred)

def test_secret(client, secret_id, token):
    # Connect with new credentials — fail if upstream not updated
    pending = client.get_secret_value(SecretId=secret_id, VersionStage="AWSPENDING")
    cred = json.loads(pending["SecretString"])
    assert can_connect(cred), "AWSPENDING credential test failed"

def finish_secret(client, secret_id, token):
    # Promote AWSPENDING to AWSCURRENT, demote old AWSCURRENT to AWSPREVIOUS
    metadata = client.describe_secret(SecretId=secret_id)
    current_version = next(
        v for v, stages in metadata["VersionIdsToStages"].items()
        if "AWSCURRENT" in stages
    )
    client.update_secret_version_stage(
        SecretId=secret_id,
        VersionStage="AWSCURRENT",
        MoveToVersionId=token,
        RemoveFromVersionId=current_version,
    )

Cái đáng học từ contract này là transactional: nếu testSecret fail, AWSCURRENT vẫn là cred cũ. Application không bao giờ thấy moment “credential nửa cũ nửa mới”. Đây là điều khó tự build bằng cron + script.

Đối với DB credential, AWS có sẵn rotation function template — clone về và customize:

# Repo Lambda template chính thức
git clone https://github.com/aws-samples/aws-secrets-manager-rotation-lambdas
cd aws-secrets-manager-rotation-lambdas/SecretsManagerRDSPostgreSQLRotationSingleUser
# 4 step đã được implement sẵn — chỉ thay env var connection string

Repo này ~370 stars — chứa template cho MySQL, PostgreSQL, Oracle, MongoDB, Redis. Cho custom secret (API key Stripe, Twilio, etc.) bạn phải tự viết — nhưng giữ đúng 4 step.

Terraform setup cho Secrets Manager + rotation

resource "aws_secretsmanager_secret" "rds_app" {
  name        = "prod/rds/app-user"
  description = "RDS app user credential, rotated every 30 days"
  kms_key_id  = aws_kms_key.secrets.arn

  recovery_window_in_days = 7
}

resource "aws_secretsmanager_secret_version" "rds_app_initial" {
  secret_id = aws_secretsmanager_secret.rds_app.id
  secret_string = jsonencode({
    username = "app_user"
    password = random_password.initial.result
    engine   = "postgres"
    host     = aws_db_instance.app.address
    port     = 5432
    dbname   = "app"
  })

  lifecycle {
    ignore_changes = [secret_string] # rotation Lambda owns updates
  }
}

resource "aws_secretsmanager_secret_rotation" "rds_app" {
  secret_id           = aws_secretsmanager_secret.rds_app.id
  rotation_lambda_arn = aws_lambda_function.rds_rotator.arn

  rotation_rules {
    automatically_after_days = 30
  }
}

# Resource policy: chỉ application role mới read được
resource "aws_secretsmanager_secret_policy" "rds_app" {
  secret_arn = aws_secretsmanager_secret.rds_app.arn
  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect    = "Allow"
      Principal = { AWS = aws_iam_role.app.arn }
      Action    = ["secretsmanager:GetSecretValue"]
      Resource  = "*"
      Condition = {
        StringEquals = {
          "secretsmanager:VersionStage" = "AWSCURRENT"
        }
      }
    }]
  })
}

recovery_window_in_days = 7 quan trọng — nếu lỡ aws_secretsmanager_secret bị xoá vì Terraform plan sai, có 7 ngày để recover. Đừng set 0.

ignore_changes = [secret_string] cũng quan trọng — Lambda rotator là owner, không phải Terraform. Nếu không có lifecycle này, mỗi lần terraform apply sẽ revert secret về initial value.

Cloudflare Secrets Store — flow distribution

Phía CF, khác hoàn toàn. Không có rotation. Không có version stage. Secrets Store chỉ là account-level storage mà bạn bind vào Worker bằng config.

// wrangler.jsonc
{
  "name": "khavan",
  "secrets_store_secrets": [
    {
      "binding": "BEDROCK_API_KEY",
      "store_id": "your-store-id-uuid",
      "secret_name": "bedrock-api-key"
    },
    {
      "binding": "OIDC_PRIVATE_KEY",
      "store_id": "your-store-id-uuid",
      "secret_name": "oidc-rsa-private-key"
    }
  ]
}

Trong Worker code:

export default {
  async fetch(request: Request, env: Env) {
    // Secret là async — không phải string global
    const apiKey = await env.BEDROCK_API_KEY.get();
    const privateKey = await env.OIDC_PRIVATE_KEY.get();

    // ... dùng để sign JWT, gọi Bedrock, v.v.
  }
};

Tạo secret qua CLI:

# Tạo store account-level (một lần)
wrangler secrets-store store create --name production

# Push secret vào store
wrangler secrets-store secret create \
  --store-id <uuid> \
  --name bedrock-api-key \
  --value "$(cat /tmp/api-key)"

# Update value — tạo version mới, ALL Worker binding vào secret này sẽ thấy ngay
wrangler secrets-store secret update \
  --store-id <uuid> \
  --name bedrock-api-key \
  --value "$(cat /tmp/api-key-new)"

Đây là điểm khác biệt lớn: trong AWS, secret có versionId, application chọn stage. Trong CF, update là destructive — phiên bản cũ vẫn lưu trong history nhưng env.X.get() luôn trả về version mới nhất. Không có concept “AWSPENDING test trước khi promote”.

Pattern replication AWS ↔ CF

Khi cần một secret ở cả hai bên (ví dụ: shared HMAC key cho webhook signing giữa Lambda và Worker), tôi dùng pattern này:

// Worker scheduled handler — chạy mỗi 6h
export default {
  async scheduled(event: ScheduledEvent, env: Env) {
    // Pull từ AWS Secrets Manager (qua OIDC federation, không long-lived key)
    const creds = await getAwsTempCreds(env);
    const aws = new AwsClient({
      ...creds,
      region: "us-east-1",
      service: "secretsmanager",
    });

    const response = await aws.fetch(
      "https://secretsmanager.us-east-1.amazonaws.com/",
      {
        method: "POST",
        headers: {
          "X-Amz-Target": "secretsmanager.GetSecretValue",
          "Content-Type": "application/x-amz-json-1.1",
        },
        body: JSON.stringify({ SecretId: "prod/webhook/signing-key" }),
      }
    );

    const { SecretString } = await response.json();
    const { current_value } = JSON.parse(SecretString);

    // Push vào CF Secrets Store via API
    await fetch(
      `https://api.cloudflare.com/client/v4/accounts/${env.CF_ACCOUNT_ID}/secrets_store/stores/${env.STORE_ID}/secrets/webhook-signing-key`,
      {
        method: "PUT",
        headers: {
          Authorization: `Bearer ${await env.CF_API_TOKEN.get()}`,
          "Content-Type": "application/json",
        },
        body: JSON.stringify({ value: current_value }),
      }
    );
  }
};

AWS là source of truth, CF là replica. Khi Lambda rotator thay secret, scheduled Worker tự pull về trong 6h. Đừng ngược lại — CF Secrets Store không có rotation, không nên làm source.

Lưu ý: secret update không tự redeploy Worker. Worker đang chạy sẽ thấy value mới ở lần env.X.get() tiếp theo (vì call này luôn fetch fresh từ Secrets Store binding, không cache).

Cost thực tế — số liệu sau 6 tháng

Setup của tôi: 12 secret AWS-side (RDS cred, Stripe webhook, Twilio API key, signing key), 8 secret CF-side (Bedrock, OIDC private key, Turnstile, AI Gateway token).

AWS Secrets Manager (us-east-1):

12 secret × $0.40 = $4.80/tháng
~50K API call/tháng × $0.05/10K = $0.25/tháng
KMS encryption call ~50K × $0.03/10K = $0.15/tháng
Lambda rotation (12 secret × 1 run/tháng × ~3s) = ~$0.02/tháng
─────────────────────────────────────────────
Total: ~$5.22/tháng

Cloudflare Secrets Store (đã có Workers Paid):

Store + secret + binding: $0 (bao trong Workers Paid $5/account/month)
Read operation: không limit
─────────────────────────────────────────────
Total: $0 incremental

Delta: ~$5/tháng cho 12 secret AWS-side. Hoàn toàn phù hợp với value vì rotation tự động.

Cảnh báo cost: ở scale lớn (500 secret) thì AWS Secrets Manager tăng tuyến tính: 500 × $0.40 = $200/tháng. Với mỗi secret bạn phải tự hỏi: có thật sự cần rotation? Nếu là static API key không bao giờ rotate (Slack webhook URL, Sentry DSN), nên dùng Parameter Store ($0/secret cho standard tier) hoặc CF Secrets Store thay vì Secrets Manager.

Khi nào dùng cái gì — quyết định trong 30 giây

Use caseAWS Secrets ManagerCF Secrets StoreParameter Store
DB credential cần rotateKhôngKhông
API key SaaS (Stripe, Twilio)Có (rotation Lambda)Có (manual rotate)Có (manual)
Worker binding (signing key, upstream auth)Có nhưng đắtKhông hỗ trợ
Compliance SOC 2 / PCI scopeCó (CloudTrail audit)Có (audit log)
Cross-region replication tự độngAccount-global mặc địnhCó nhưng pay
Cross-AWS-account shareCó (resource policy)Không nativeKhông
Webhook secret (HMAC verify)OK nhưng overkillTốtOK
OIDC RSA private key (JWT signing)OKTốt nhất
Config flag không sensitiveKhông (lãng phí)Không (overkill)

Audit và compliance — chỗ AWS thắng rõ ràng

AWS CloudTrail log mọi GetSecretValue, PutSecretValue, RotateSecret. Tích hợp tự nhiên với Athena query, GuardDuty alert, Security Hub finding. Đây là chuẩn để pass SOC 2 Type II audit cho “credential management” control.

-- Athena query: ai đọc secret prod/rds/app-user trong 30 ngày qua
SELECT
  eventTime,
  userIdentity.arn AS principal,
  sourceIPAddress,
  requestParameters.versionStage AS stage
FROM cloudtrail_logs
WHERE eventSource = 'secretsmanager.amazonaws.com'
  AND eventName = 'GetSecretValue'
  AND requestParameters.secretId LIKE '%prod/rds/app-user%'
  AND eventTime > date_add('day', -30, current_date)
ORDER BY eventTime DESC;

Cloudflare Secrets Store cũng có audit log qua Cloudflare Audit Logs API, nhưng không chi tiết bằng — log “secret created/updated/deleted”, không log từng get() operation từ Worker. Đó là trade-off: less observability, less cost overhead.

Cạm bẫy thường gặp

1. Quên recovery_window_in_days. Default 30 ngày. Khi đang test mà set 0 để xoá nhanh — sau đó muốn recover không được. Để default 7-30 cho production.

2. Rotation Lambda quên RoleAssumePolicy đúng. Lambda phải có quyền secretsmanager:GetSecretValue, PutSecretValue, UpdateSecretVersionStage cho chính secret đó. Test với aws secretsmanager rotate-secret --secret-id <arn> rồi xem CloudWatch Logs.

3. Application cache secret quá lâu. SDK AWS SDK v3 có SecretsManagerCachingClient — đặt TTL ≤ 1h. Nếu cache 24h thì rotation 30 phút sau, app vẫn dùng cred cũ → DB connection fail.

4. CF Secrets Store binding trong dev. Local wrangler dev không tự sync secret từ remote store. Phải wrangler secrets-store secret get rồi đưa vào .dev.vars. Hoặc dùng --remote flag để hit remote runtime.

5. Hardcode KMS key ID trong Terraform. Mỗi region có KMS key khác. Dùng aws_kms_alias hoặc aws/secretsmanager default key trừ khi cần customer-managed.

Bottom line

AWS Secrets Manager và Cloudflare Secrets Store không phải competitor — chúng giải quyết hai nửa khác nhau của secret management. AWS thắng ở rotation logic + audit + cross-account, là default cho mọi regulated workload và DB credential. CF Secrets Store thắng ở distribution vào Worker runtime + cost free trên Workers Paid, là default cho Worker binding. Pattern thực tế là dùng cả hai với AWS là source of truth, CF là edge replica cho những secret nào Worker cần. Đừng cố unify — đó là cách tốt nhất để build một abstraction layer mà sau 3 tháng không ai hiểu.

Checklist trước khi commit setup:

  • Mọi secret AWS có recovery_window_in_days ≥ 7
  • Mọi rotation Lambda implement đủ 4 step (createSecret → setSecret → testSecret → finishSecret)
  • Resource policy giới hạn principal (chỉ application role read được, không *)
  • Application cache secret TTL ≤ 1h
  • CloudTrail data event bật cho secret nhạy cảm (prod/*)
  • CF Secrets Store binding khai trong wrangler.jsonc, không hardcode trong code
  • Scheduled Worker replicate (nếu cần) chạy ít nhất mỗi 6h, idempotent
  • KMS key cho Secrets Manager là customer-managed cho regulated workload
  • Cost monitoring: alert khi secret count > 100 (rà soát có dùng nữa không)
  • DR plan: backup secret value ra một bucket encrypted khác (cross-region) cho top-10 critical secret

Tham chiếu