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à:
- Key Storage Layer — lưu trữ an toàn, mã hoá, versioning.
- Runtime Injection Layer — luồng request lúc gọi API thực tế.
- 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:Decrypttrênencrypted_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ụ:
- Key được chỉ định cụ thể cho feature/model đó (nếu tenant cấu hình riêng).
- Key mặc định (
alias = 'default') của tenant cho provider đó. - Nếu không có BYOK nào active, và
fallbackToPlatform = true→ dùng key hệ thống, đồng thời gắnbillingMode = 'platform'để hệ thống billing tính phí đúng. - 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
decryptnê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/modelshoặc tương đương) cho các key đã lâu không được validate, cập nhậtlast_validated_atvàstatus. - 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 = revokedmộ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: GenerateDataKey và Decrypt đề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)
- AWS KMS — Envelope encryption (Developer Guide) — giải thích chính thức về cơ chế mã hoá DEK dưới CMK.
- AWS KMS — Generate data keys — workflow
GenerateDataKeyvàDecrypt, kèm khuyến nghị xoá plaintext key khỏi memory sau khi dùng. - AWS KMS —
GenerateDataKeyAPI Reference — chi tiết tham số API, giới hạn 4KB, và yêu cầu IAM permission. - AWS KMS — Cryptography essentials — thuật toán FIPS-approved, AES-256-GCM, lý do dùng envelope encryption cho dữ liệu lớn.
Bảo mật & quản lý API key của LLM provider
- OpenAI — Best Practices for API Key Safety — hướng dẫn chính thức về lưu trữ key, IP allowlisting, rotation.
- OpenAI — Production best practices — đề xuất dùng secret manager, tách project theo môi trường, scaling.
- OpenAI — How to keep your account secure — cơ chế tự động vô hiệu hoá key bị lộ, spend threshold, shared responsibility model.
- Anthropic — API documentation — tham khảo định dạng request/auth header cho Claude API khi triển khai multi-provider.
Secrets management & nguyên tắc kiến trúc chung
- OWASP — Secrets Management Cheat Sheet — vòng đời secret (tạo/lưu/xoay/thu hồi/audit), metadata cần lưu, least-privilege, centralization.
- OWASP — Key Management Cheat Sheet — chi tiết về quản lý vòng đời khoá mã hoá.
- AWS — Encryption best practices and use cases — hướng dẫn prescriptive của AWS về mã hoá at-rest, in-transit và phân lớp khoá.
