Mở đầu: Vì sao BYOK đang trở thành tiêu chuẩn ngầm

Nếu bạn đang xây một sản phẩm có tích hợp LLM — chatbot, agent, tool gọi OpenAI/Anthropic — sớm hay muộn bạn sẽ gặp câu hỏi này từ khách hàng enterprise: "Tôi có thể dùng API key của chính mình không?"

Lý do họ hỏi không phải vì tiết kiệm vài đô. Đó là:

  • Compliance & data residency: một số tổ chức bị ràng buộc hợp đồng hoặc quy định pháp lý, không được để traffic AI đi qua billing account của bên thứ ba.
  • Rate limit & quota riêng: họ đã có tier cao với OpenAI/Anthropic, không muốn bị giới hạn bởi quota chung của SaaS bạn đang vận hành.
  • Kiểm soát chi phí: enterprise muốn nhìn thấy chi phí AI trực tiếp trên dashboard billing của họ, không qua markup của bạn.
  • Tách rủi ro vendor lock-in: nếu họ đổi provider, họ không phụ thuộc vào việc bạn có hỗ trợ kịp hay không.

BYOK (Bring Your Own Key) giải quyết đúng vấn đề đó: thay vì hệ thống của bạn dùng một key trung tâm để gọi LLM cho tất cả người dùng, mỗi tenant/người dùng tự cung cấp API key của riêng họ (OpenAI, Anthropic, Azure OpenAI, v.v.), và hệ thống của bạn chỉ đóng vai trò orchestration — định tuyến, áp dụng business logic, nhưng không sở hữu thẻ tín dụng hay chịu trách nhiệm billing cho lưu lượng AI đó.

Nghe đơn giản, nhưng làm đúng thì có khá nhiều bẫy: lưu key thế nào để không lộ ra production logs, runtime injection key sao cho không tăng latency, multi-tenancy ra sao khi một user có thể có nhiều key cho nhiều provider, và rotate/revoke key thế nào khi không có quyền truy cập trực tiếp vào tài khoản của provider.

Bài viết này đi qua kiến trúc đầy đủ của một hệ thống BYOK cho LLM, với các ví dụ minh hoạ bằng NestJS/Node.js trên AWS — một stack phổ biến cho loại hệ thống này, nhưng các nguyên tắc kiến trúc áp dụng được cho bất kỳ ngôn ngữ/nền tảng nào.

1. Mô hình tổng quan: BYOK đứng ở đâu trong request lifecycle

Trước khi đi vào chi tiết, hãy hình dung một request lifecycle điển hình:

Client → API Gateway/ALB → NestJS App
                                │
                                ├─ 1. Xác định tenant/user
                                ├─ 2. Resolve API key (BYOK hoặc fallback key hệ thống)
                                ├─ 3. Decrypt key (KMS/Vault)
                                ├─ 4. Inject key vào LLM client (runtime, không cache plaintext lâu)
                                ├─ 5. Gọi LLM provider (OpenAI/Anthropic/...)
                                ├─ 6. Stream/aggregate response
                                └─ 7. Log usage (không log key) + billing reconciliation

Ba khối kiến trúc cốt lõi mà bài viết này tập trung là:

  1. Key Storage Layer — lưu trữ an toàn, mã hoá, versioning.
  2. Runtime Injection Layer — luồng request lúc gọi API thực tế.
  3. Multi-tenancy & Key Management Layer — quản lý nhiều key, nhiều provider, theo từng tenant.

2. Bảo mật lưu trữ Key (Storage Layer)

2.1. Nguyên tắc bất biến: không bao giờ lưu plaintext

API key của OpenAI/Anthropic về bản chất tương đương với một "bearer credential" — ai có key đó coi như có quyền chi tiêu trên tài khoản người dùng. Vì vậy nguyên tắc đầu tiên không thể thoả hiệp: không bao giờ lưu key ở dạng plaintext trong database, dù là Postgres, DynamoDB hay Redis.

Có ba lựa chọn phổ biến, theo thứ tự độ phức tạp tăng dần:

Option A — Envelope Encryption với AWS KMS

Đây là lựa chọn phù hợp nhất cho hầu hết hệ thống chạy trên AWS, vì tận dụng được hạ tầng IAM sẵn có.

Cơ chế:

  • Tạo một Customer Master Key (CMK) trong KMS, ví dụ alias/byok-master-key.
  • Khi người dùng nhập API key, hệ thống gọi kms:GenerateDataKey để lấy một Data Encryption Key (DEK) — KMS trả về cả plaintext DEK và ciphertext DEK.
  • Dùng plaintext DEK để mã hoá API key bằng AES-256-GCM, sau đó xoá plaintext DEK khỏi memory ngay lập tức.
  • Lưu vào DB: encrypted_key (ciphertext của API key) + encrypted_dek (ciphertext của DEK) + iv/auth_tag.
  • Khi cần dùng: gọi kms:Decrypt trên encrypted_dek để lấy lại plaintext DEK, dùng nó decrypt API key, rồi xoá khỏi memory sau khi dùng xong.

Đây gọi là envelope encryption — bạn không gọi KMS để decrypt trực tiếp API key (tốn chi phí + có giới hạn kích thước 4KB cho KMS), mà chỉ dùng KMS để bảo vệ DEK, còn DEK bảo vệ data thực tế.

Ví dụ minh hoạ trong NestJS (rút gọn, bỏ qua error handling đầy đủ):

// kms-encryption.service.ts
import { KMSClient, GenerateDataKeyCommand, DecryptCommand } from '@aws-sdk/client-kms';
import * as crypto from 'crypto';

@Injectable()
export class KmsEncryptionService {
  private kms = new KMSClient({ region: process.env.AWS_REGION });
  private readonly keyId = process.env.KMS_KEY_ID;

  async encryptApiKey(plaintextKey: string): Promise<EncryptedPayload> {
    const { Plaintext, CiphertextBlob } = await this.kms.send(
      new GenerateDataKeyCommand({ KeyId: this.keyId, KeySpec: 'AES_256' }),
    );

    const iv = crypto.randomBytes(12);
    const cipher = crypto.createCipheriv('aes-256-gcm', Plaintext, iv);
    const encrypted = Buffer.concat([
      cipher.update(plaintextKey, 'utf8'),
      cipher.final(),
    ]);

    // Xoá plaintext DEK khỏi memory ngay
    Plaintext.fill(0);

    return {
      encryptedKey: encrypted.toString('base64'),
      encryptedDek: Buffer.from(CiphertextBlob).toString('base64'),
      iv: iv.toString('base64'),
      authTag: cipher.getAuthTag().toString('base64'),
    };
  }

  async decryptApiKey(payload: EncryptedPayload): Promise<string> {
    const { Plaintext: dek } = await this.kms.send(
      new DecryptCommand({
        CiphertextBlob: Buffer.from(payload.encryptedDek, 'base64'),
      }),
    );

    const decipher = crypto.createDecipheriv(
      'aes-256-gcm',
      dek,
      Buffer.from(payload.iv, 'base64'),
    );
    decipher.setAuthTag(Buffer.from(payload.authTag, 'base64'));

    const decrypted = Buffer.concat([
      decipher.update(Buffer.from(payload.encryptedKey, 'base64')),
      decipher.final(),
    ]);

    dek.fill(0); // xoá DEK khỏi memory sau khi dùng

    return decrypted.toString('utf8');
  }
}

Lưu ý quan trọng: phải set IAM policy cho KMS key sao cho chỉ service role cụ thể (ví dụ ECS Task Role của service xử lý LLM request) mới có quyền kms:Decrypt. Nên tách biệt rõ giữa Task Execution Role (kéo image, ghi log) và Task Role (quyền nghiệp vụ): service ghi key (write path) nên có quyền GenerateDataKey, còn service đọc key lúc runtime chỉ cần Decrypt, không cần GenerateDataKey. Nguyên tắc least-privilege này thu hẹp đáng kể bề mặt rủi ro nếu một service bị compromise.

Option B — Dùng AWS Secrets Manager / Parameter Store

Phù hợp nếu số lượng key không lớn (vài trăm đến vài nghìn), vì Secrets Manager tính phí theo số secret/tháng. Cách này đơn giản hoá việc rotation vì Secrets Manager có sẵn cơ chế rotation, nhưng không scale tốt cho mô hình SaaS có hàng chục nghìn tenant tự thêm key — lúc đó envelope encryption tự quản lý trong DB sẽ rẻ và linh hoạt hơn.

Option C — HashiCorp Vault / external secret store

Cân nhắc khi hệ thống của bạn đã multi-cloud hoặc cần một secret store độc lập với AWS. Phức tạp hơn để vận hành (cần tự quản lý Vault cluster hoặc dùng HCP Vault), nhưng cho khả năng audit trail và dynamic secrets mạnh hơn.

Khuyến nghị cho hệ thống NestJS chạy trên ECS: Option A (KMS + envelope encryption) là điểm cân bằng tốt nhất giữa chi phí, độ phức tạp vận hành, và tích hợp tự nhiên với IAM trên AWS.

2.2. Schema lưu trữ

Một schema tối thiểu cho bảng lưu key, theo mô hình multi-tenant, multi-provider:

CREATE TABLE byok_credentials (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id UUID NOT NULL REFERENCES tenants(id),
  provider VARCHAR(50) NOT NULL,        -- 'openai' | 'anthropic' | 'azure_openai'
  alias VARCHAR(100),                    -- tên gợi nhớ do user đặt
  encrypted_key TEXT NOT NULL,
  encrypted_dek TEXT NOT NULL,
  iv VARCHAR(50) NOT NULL,
  auth_tag VARCHAR(50) NOT NULL,
  key_fingerprint VARCHAR(64) NOT NULL,  -- hash để nhận diện key trùng, KHÔNG dùng để decrypt
  status VARCHAR(20) NOT NULL DEFAULT 'active', -- active | revoked | invalid
  last_validated_at TIMESTAMPTZ,
  last_used_at TIMESTAMPTZ,
  created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  UNIQUE (tenant_id, provider, alias)
);

CREATE INDEX idx_byok_tenant_provider ON byok_credentials(tenant_id, provider, status);

Vài điểm đáng chú ý:

  • key_fingerprint: một hash một chiều (ví dụ SHA-256 của key gốc + salt cố định) để hệ thống có thể phát hiện "key này đã được thêm trước đó chưa" mà không cần decrypt. Hữu ích để tránh user vô tình thêm trùng key, hoặc để phát hiện khi cùng một key bị dùng ở nhiều tenant (dấu hiệu rò rỉ).
  • status: không xoá hẳn key khi user revoke — soft-delete để giữ audit trail, đồng thời tránh trường hợp orphaned reference từ các request log cũ.
  • last_validated_at: timestamp lần cuối hệ thống gọi một lightweight request (ví dụ GET /v1/models) để xác nhận key còn hợp lệ — quan trọng để phát hiện key bị revoke từ phía provider trước khi user gặp lỗi giữa luồng nghiệp vụ.

3. Luồng Request Runtime: Proxy & Key Injection

Đây là phần nhiều người đánh giá thấp độ phức tạp. Vấn đề không chỉ là "decrypt key rồi gọi API" — mà là làm sao không để key tồn tại lâu hơn cần thiết trong memory, không lọt vào log, không tăng latency đáng kể, và vẫn hỗ trợ streaming.

3.1. Vị trí đặt BYOK Resolver trong NestJS

Cách tổ chức hợp lý là tách một BYOKInterceptor hoặc middleware riêng, chạy trước khi request chạm vào LLM client, chứ không nhúng logic decrypt rải rác trong từng service.

// byok.interceptor.ts
@Injectable()
export class ByokInterceptor implements NestInterceptor {
  constructor(
    private readonly credentialService: CredentialService,
    private readonly kms: KmsEncryptionService,
  ) {}

  async intercept(context: ExecutionContext, next: CallHandler) {
    const req = context.switchToHttp().getRequest();
    const tenantId = req.tenantId; // gán từ AuthGuard trước đó
    const provider = req.body.provider ?? 'openai';

    const credential = await this.credentialService.resolve(tenantId, provider);

    if (!credential) {
      // Fallback: dùng key hệ thống (nếu sản phẩm hỗ trợ cả 2 mô hình)
      req.llmApiKey = this.credentialService.getSystemFallbackKey(provider);
      req.billingMode = 'platform';
    } else {
      req.llmApiKey = await this.kms.decryptApiKey(credential.payload);
      req.billingMode = 'byok';
    }

    return next.handle().pipe(
      finalize(() => {
        // Xoá reference khỏi request object sau khi response đã gửi xong
        req.llmApiKey = null;
      }),
    );
  }
}

Điểm mấu chốt: req.llmApiKey chỉ tồn tại trong vòng đời của một request, không bao giờ được cache lại ở tầng nào khác (không Redis, không log, không gắn vào context truyền sang queue).

3.2. Tuyệt đối không log key — kể cả vô tình

Đây là lỗi thực tế hay gặp nhất: NestJS logger interceptor mặc định log toàn bộ request.body hoặc request.headers để debug, và nếu key được truyền qua header (Authorization: Bearer sk-...) hoặc nằm trong payload, nó sẽ vô tình bị ghi vào CloudWatch Logs.

Giải pháp: dùng một redaction layer tập trung, không dựa vào việc dev nhớ phải che field.

const SENSITIVE_PATTERNS = [/sk-[a-zA-Z0-9]{20,}/g, /sk-ant-[a-zA-Z0-9-]{20,}/g];

function redactSensitive(input: string): string {
  return SENSITIVE_PATTERNS.reduce(
    (acc, pattern) => acc.replace(pattern, '[REDACTED_KEY]'),
    input,
  );
}

Áp dụng pattern này ở tầng global logger (ví dụ custom Winston transformer), không chỉ ở nơi bạn nghĩ là "có khả năng" chứa key — vì exception stack trace, error response từ provider, hay thậm chí APM tracing payload (Datadog, New Relic) cũng có thể vô tình mang key đi theo.

3.3. Streaming và vấn đề giữ key "sống" trong suốt response

Với non-streaming request, key chỉ cần tồn tại trong khoảnh khắc gọi API. Nhưng với streaming (SSE từ OpenAI/Anthropic), connection có thể kéo dài vài chục giây — và nếu hệ thống dùng kiến trúc persist chunk (ví dụ Redis Streams) cho khả năng reconnect/replay, cần đặc biệt cẩn thận: chunk dữ liệu lưu trữ trung gian phải là response content, không phải API key — đừng để bất kỳ phần nào của request ban đầu (bao gồm key) bị serialize vào payload được lưu lại.

Một pattern an toàn: decrypt key ngay trước khi mở connection tới provider, giữ nó trong một biến local scope hẹp nhất có thể (không gắn vào object lớn hơn được pass qua nhiều layer), và để nó out-of-scope (GC tự dọn) ngay sau khi HTTP client tới provider đã nhận key vào header.

async function streamFromProvider(tenantId: string, provider: string, payload: any) {
  const apiKey = await resolveAndDecryptKey(tenantId, provider); // scope hẹp

  const upstream = await fetch(getProviderEndpoint(provider), {
    method: 'POST',
    headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json' },
    body: JSON.stringify(payload),
  });
  // apiKey không được tham chiếu ở đâu khác sau dòng này

  return upstream.body; // pipe tiếp vào SSE response, hoặc publish chunk vào Redis Stream
}

3.4. Retry & error mapping — khác biệt quan trọng so với platform key

Khi dùng key hệ thống, một lỗi 429 hay 401 là vấn đề nội bộ bạn tự xử lý (rotate key dự phòng, báo team vận hành). Khi dùng BYOK, lỗi đó là vấn đề của user — họ cần biết chính xác là do key họ hết hạn, hết quota, hay do hệ thống bạn có bug. Vì vậy luồng error handling cần phân loại rõ:

Mã lỗi từ provider Nguyên nhân khả dĩ Hành động hệ thống
401 Key sai/đã revoke Đánh dấu status = invalid, báo user qua UI/email
429 Vượt rate limit của user's own account Trả lỗi rõ ràng "rate limit từ tài khoản OpenAI của bạn", không phải lỗi hệ thống
500/503 từ provider Lỗi tạm thời phía provider Retry với backoff, không đánh dấu key invalid
Timeout Network hoặc provider chậm Retry có giới hạn, log riêng để phân biệt khỏi lỗi key

Nhầm lẫn giữa các loại lỗi này là nguồn gốc phổ biến của ticket support gây hiểu lầm — user nghĩ sản phẩm bạn lỗi, trong khi thực ra là quota account OpenAI của họ đã hết.

4. Multi-Tenancy & Quản lý Nhiều Key/Provider

4.1. Mô hình dữ liệu cho multi-provider

Một tenant thực tế hiếm khi chỉ dùng một provider. Họ có thể dùng GPT-4 cho một feature, Claude cho feature khác (ví dụ vì context window dài hơn), hoặc dùng Azure OpenAI vì lý do compliance khu vực. Hệ thống cần một lớp routing theo provider + theo model tách biệt khỏi lớp lưu trữ key.

interface ModelRoute {
  tenantId: string;
  feature: string;        // 'chat' | 'summarize' | 'agent-loop'
  provider: 'openai' | 'anthropic' | 'azure_openai';
  model: string;
  credentialId: string;   // FK tới byok_credentials
  fallbackToPlatform: boolean; // nếu BYOK fail, có cho phép fallback về key hệ thống không
}

Việc tách ModelRoute khỏi Credential cho phép tenant đổi model mà không cần đổi key, và đổi key mà không ảnh hưởng routing logic — hai vòng đời thay đổi với tốc độ khác nhau.

4.2. Resolve order: chiến lược chọn key khi có nhiều lựa chọn

Khi một request tới, CredentialService.resolve() cần một thứ tự ưu tiên rõ ràng, ví dụ:

  1. Key được chỉ định cụ thể cho feature/model đó (nếu tenant cấu hình riêng).
  2. Key mặc định (alias = 'default') của tenant cho provider đó.
  3. Nếu không có BYOK nào active, và fallbackToPlatform = true → dùng key hệ thống, đồng thời gắn billingMode = 'platform' để hệ thống billing tính phí đúng.
  4. Nếu không có gì khả dụng → trả lỗi rõ ràng cho client, không silently fail.

Đây là logic nên được unit test kỹ, vì sai sót ở đây dẫn tới hậu quả nghiêm trọng: dùng nhầm key hệ thống cho traffic lẽ ra phải tính vào tài khoản BYOK của khách (rủi ro chi phí), hoặc ngược lại từ chối request hợp lệ.

4.3. Cách ly theo tenant ở tầng hạ tầng (không chỉ tầng logic)

Nếu sản phẩm của bạn phục vụ cả thị trường Việt Nam và Nhật Bản với yêu cầu compliance khác nhau, riêng việc kiểm tra tenant ID trong application logic là chưa đủ trong các audit nghiêm ngặt. Một số chiến lược bổ sung:

  • Tách KMS key theo region/tenant tier: enterprise tenant lớn có thể yêu cầu CMK riêng (alias/byok-tenant-{id}) thay vì share một CMK chung — tăng chi phí KMS nhưng đáp ứng yêu cầu "key isolation" trong hợp đồng.
  • VPC endpoint cho KMS: nếu ECS task gọi KMS, dùng VPC Endpoint (Interface Endpoint) thay vì đi qua NAT Gateway ra internet — vừa giảm chi phí NAT, vừa giảm bề mặt tấn công vì traffic không rời khỏi mạng nội bộ AWS.
  • Audit log riêng cho hành vi truy cập credential: mọi lần decrypt nên ghi vào một audit trail riêng (ai/khi nào/tenant nào), tách biệt khỏi application log thông thường, và log này nên có retention dài hơn (phục vụ điều tra sau này) nhưng access control nghiêm ngặt hơn.

4.4. Rotation và Revocation

BYOK đặt ra một thực tế khó chịu: bạn không kiểm soát được lifecycle của key — đó là quyền của user và provider. Hệ thống cần một cơ chế chủ động phát hiện key đã hết hiệu lực thay vì chỉ phát hiện khi user report lỗi:

  • Background validation job: một cron job (CloudWatch Events → Lambda, hoặc BullMQ job trong NestJS) định kỳ gọi lightweight endpoint (/v1/models hoặc tương đương) cho các key đã lâu không được validate, cập nhật last_validated_atstatus.
  • Webhook/notify khi key invalid: khi phát hiện key lỗi giữa luồng nghiệp vụ thực (không phải qua validation job), nên trigger thông báo ngay (email, in-app notification) — đừng để user tự phát hiện qua việc feature không hoạt động.
  • Grace period trước khi xoá hẳn: khi user revoke key qua UI, giữ ở status = revoked một khoảng thời gian (ví dụ 30 ngày) trước khi xoá record hẳn, để hỗ trợ trường hợp họ cần khôi phục lịch sử sử dụng cho mục đích billing reconciliation.

5. Một vài cân nhắc vận hành thực tế

Chi phí KMS ở quy mô lớn: GenerateDataKeyDecrypt đều tính phí theo request (ngoài free tier). Ở quy mô hàng triệu request/ngày, decrypt key cho mỗi request riêng lẻ có thể tích lũy chi phí đáng kể. Giải pháp phổ biến: cache plaintext DEK (không phải API key) trong memory với TTL ngắn (vài phút), giảm số lần gọi KMS, miễn là vẫn tuân thủ chính sách bảo mật nội bộ về thời gian tồn tại của secret trong memory.

Testing mà không cần real key: dùng provider mock/sandbox (OpenAI có test mode hạn chế, hoặc tự dựng mock server giả lập response format của OpenAI/Anthropic) để CI/CD không cần thật sự gọi LLM provider — tránh leak test key vào pipeline log, và tránh chi phí phát sinh từ test chạy lặp lại.

Giám sát chi phí hộ user (dù không quản lý billing của họ): nhiều sản phẩm BYOK vẫn cung cấp dashboard ước tính usage (token count, số request) dù không trực tiếp thu tiền — giúp user tin tưởng hệ thống minh bạch, đồng thời giảm support load vì họ tự theo dõi được mà không cần hỏi bạn "tôi đã dùng bao nhiêu rồi?"

6. Tổng kết

BYOK không phải là một feature đơn lẻ ("thêm field nhập API key vào settings") mà là một thay đổi kiến trúc xuyên suốt: từ storage layer (envelope encryption với KMS), runtime layer (resolver/interceptor không để key tồn tại lâu hơn cần thiết, redaction log nghiêm ngặt), tới multi-tenancy layer (routing tách biệt khỏi credential, audit trail riêng, chiến lược rotation/revocation).

Điểm khó nhất không nằm ở mã hoá — AES-256-GCM hay KMS đều là công nghệ chuẩn, dễ tích hợp. Điểm khó nằm ở kỷ luật vận hành: đảm bảo key không bao giờ vô tình lọt vào log, error message, hay cache trung gian — những nơi mà một dòng code tưởng chừng vô hại (console.log(req.body), một APM agent ghi full payload) có thể biến thành lỗ hổng bảo mật nghiêm trọng.

Tài liệu tham khảo

Envelope encryption & Key Management (AWS KMS)

Bảo mật & quản lý API key của LLM provider

Secrets management & nguyên tắc kiến trúc chung