-
Notifications
You must be signed in to change notification settings - Fork 3
Virtual Keys
Virtual keys are client-facing API keys that provide authenticated access to the /v1/* proxy endpoints. They enable per-client rate limiting, token usage tracking, and audit logging without exposing provider API keys.
- Client includes a virtual key in the
Authorization: Bearer <virtual-key>header - Proxy hashes the key with SHA-256 and looks it up against the
virtual_keystable (1 DB query) - If the hash matches, the proxy sets the key identity in the request context
- Proxy looks up the requested model and provider
- Proxy decrypts the provider's real API key using
MASTER_KEY - Request is forwarded to the provider with the real API key
- Proxy streams the response back, logging token usage against the virtual key
Virtual keys follow the format: sk-<32 hex characters>
-
Prefix:
sk-(secret key identifier) - Payload: 32 hexadecimal characters (16 bytes of cryptographic randomness)
- Total length: 35 characters
-
Example:
sk-a1b2c3d4e5f6789012345678abcdef01
Keys are generated using Go's crypto/rand package (CSPRNG):
func Generate() (string, error) {
key := make([]byte, 16)
if _, err := io.ReadFull(cryptoRand.Reader, key); err != nil {
return "", err
}
return "sk-" + hex.EncodeToString(key), nil
}-
Source:
crypto/rand- cryptographically secure pseudo-random number generator - Entropy: 128 bits (16 bytes Γ 8 bits)
- Encoding: Lowercase hexadecimal
- Uniqueness: Probability of collision is negligible (~1 in 2ΒΉΒ²βΈ)
The key_preview field stores a human-readable identifier for the key:
-
Format: First 3 characters (including
sk-prefix) +...+ last 2 characters -
Example:
sk-a1b2c3d4e5f6789012345678abcdef01βsk-...01 - Purpose: UI identification without exposing the full key
-
Storage: Stored in plaintext alongside the hash in the
virtual_keystable
Virtual keys are never stored in plaintext. Only the SHA-256 hash is persisted:
func Hash(key string) string {
hash := sha256.Sum256([]byte(key))
return hex.EncodeToString(hash[:])
}| Property | Value |
|---|---|
| Algorithm | SHA-256 (FIPS 180-4) |
| Output | 256-bit (32-byte) digest |
| Encoding | Lowercase hexadecimal (64 characters) |
| Deterministic | Same input β same output |
| One-way | Cannot recover key from hash |
-
Storage: Only 64-character hex hash stored in
key_hashcolumn - Lookup: Incoming requests hash the provided token and compare hashes
- Deletion: Removing a key from the database immediately invalidates it
- Rotation: Create a new key, update clients, delete the old key
- Recovery: Lost keys cannot be recovered - generate a new one
HTTP Request
β
βΌ
ProxyKeyMiddleware (internal/proxy/handler.go:176)
β
βββΊ ParseBearerToken() β extract token from "Authorization: Bearer <token>"
β
βββΊ virtualkey.Hash(token) β compute SHA-256
β
βββΊ FindByKeyHash(keyHash) β database lookup
β β
β βββΊ NOT FOUND β 401 Unauthorized
β βββΊ FOUND β continue
β
βββΊ Set context values:
β β’ virtual_key_name
β β’ virtual_key_id (UUID)
β β’ virtual_key_hash
β β’ virtual_key_rate_limit_rps
β β’ virtual_key_rate_limit_burst
β
βββΊ TouchLastUsed(keyHash) β async update (5s timeout, fire-and-forget)
β
βββΊ next.ServeHTTP() β continue to rate limiter β proxy handler
func (h *Handler) ProxyKeyMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// 1. Extract Bearer token
token, ok := util.ParseBearerToken(r)
if !ok {
writeOpenAIError(w, "Authorization header required (Bearer token)", http.StatusUnauthorized)
return
}
// 2. Hash the token
keyHash := virtualkey.Hash(token)
// 3. Database lookup
vk, err := h.virtualKeyRepo.FindByKeyHash(r.Context(), keyHash)
if err != nil {
if errors.Is(err, virtualkey.ErrNotFound) {
writeOpenAIError(w, "Invalid virtual key", http.StatusUnauthorized)
} else {
writeOpenAIError(w, "Internal error", http.StatusInternalServerError)
}
return
}
// 4. Set context values for downstream handlers
ctx := context.WithValue(r.Context(), virtualKeyNameKey, vk.Name)
ctx = context.WithValue(ctx, virtualKeyIDKey, vk.ID)
ctx = context.WithValue(ctx, VirtualKeyHashKey, keyHash)
ctx = context.WithValue(ctx, ctxkeys.VirtualKeyRateLimitRPSKey, vk.RateLimitRPS)
ctx = context.WithValue(ctx, ctxkeys.VirtualKeyRateLimitBurstKey, vk.RateLimitBurst)
// 5. Async last-used update (non-blocking)
go func(hash string) {
tctx, tcancel := context.WithTimeout(context.Background(), 5*time.Second)
defer tcancel()
h.virtualKeyRepo.TouchLastUsed(tctx, hash)
}(keyHash)
next.ServeHTTP(w, r.WithContext(ctx))
})
}After successful authentication, the following values are available in request context:
| Key | Type | Purpose |
|---|---|---|
virtual_key_name |
string |
Human-readable key name (e.g., "production-app") |
virtual_key_id |
uuid.UUID |
Database primary key |
virtual_key_hash |
string |
SHA-256 hash (64 hex chars) |
virtual_key_rate_limit_rps |
*float64 |
Per-key RPS override (nil = use global) |
virtual_key_rate_limit_burst |
*int |
Per-key burst override (nil = use global) |
CREATE TABLE IF NOT EXISTS virtual_keys (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
key_hash TEXT NOT NULL UNIQUE,
key_preview TEXT NOT NULL DEFAULT '',
tokens_used BIGINT NOT NULL DEFAULT 0,
last_used_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT now(),
rate_limit_rps DOUBLE PRECISION DEFAULT NULL,
rate_limit_burst INTEGER DEFAULT NULL
);
CREATE INDEX IF NOT EXISTS idx_virtual_keys_key_hash ON virtual_keys(key_hash);| Column | Type | Constraints | Description |
|---|---|---|---|
id |
UUID |
PRIMARY KEY, DEFAULT gen_random_uuid()
|
Unique identifier |
name |
TEXT |
NOT NULL | Human-readable name (1-100 chars, printable) |
key_hash |
TEXT |
NOT NULL, UNIQUE | SHA-256 hash (64 hex characters) |
key_preview |
TEXT |
NOT NULL | First 3 + last 2 chars (e.g., sk-...01) |
tokens_used |
BIGINT |
NOT NULL, DEFAULT 0 | Cumulative token count (prompt + completion) |
last_used_at |
TIMESTAMPTZ |
NULLABLE | Last authentication timestamp |
created_at |
TIMESTAMPTZ |
DEFAULT now()
|
Creation timestamp |
rate_limit_rps |
DOUBLE PRECISION |
NULLABLE | Per-key RPS override (null = global default) |
rate_limit_burst |
INTEGER |
NULLABLE | Per-key burst override (null = global default) |
| Migration | File | Changes |
|---|---|---|
004 |
internal/db/migrations/004_virtual_keys.sql |
Initial table creation |
005 |
internal/db/migrations/005_virtual_key_preview.sql |
Added key_preview column |
012 |
internal/db/migrations/012_add_virtual_key_id_to_request_logs.sql |
Added virtual_key_id to request_logs
|
029 |
internal/db/migrations/029_virtual_key_rate_limits.sql |
Added per-key rate limit columns |
All virtual key endpoints require Authorization: Bearer $ADMIN_TOKEN header.
Endpoint: POST /api/virtual-keys
Request Body:
{
"name": "production-app",
"rate_limit_rps": 5.0,
"rate_limit_burst": 10
}| Field | Type | Required | Constraints |
|---|---|---|---|
name |
string |
Yes | 1-100 chars, printable Unicode, not reserved |
rate_limit_rps |
number |
No | Must be β₯ 0 (null = use global default) |
rate_limit_burst |
integer |
No | Must be β₯ 1 (null = use global default) |
Reserved Names (cannot be used):
chatarenacompletionsadmin
These are reserved because they conflict with built-in URL paths.
Response (201 Created):
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"name": "production-app",
"key": "sk-a1b2c3d4e5f6789012345678abcdef01",
"key_preview": "sk-...01",
"tokens_used": 0,
"last_used_at": null,
"created_at": "2025-01-15T10:30:00Z",
"rate_limit_rps": 5.0,
"rate_limit_burst": 10
}
β οΈ Critical: Thekeyfield is returned only once on creation. It is never returned in subsequent API calls. Store it securely immediately.
Endpoint: GET /api/virtual-keys
Response (200 OK):
[
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"name": "production-app",
"key": "",
"key_preview": "sk-...01",
"tokens_used": 125000,
"last_used_at": "2025-01-15T14:22:00Z",
"created_at": "2025-01-15T10:30:00Z",
"rate_limit_rps": 5.0,
"rate_limit_burst": 10
},
{
"id": "660e8400-e29b-41d4-a716-446655440001",
"name": "dev-testing",
"key": "",
"key_preview": "sk-...ab",
"tokens_used": 4500,
"last_used_at": null,
"created_at": "2025-01-14T08:15:00Z",
"rate_limit_rps": null,
"rate_limit_burst": null
}
]Note: key is always empty string in list/get responses. rate_limit_* fields are null if using global defaults.
Endpoint: GET /api/virtual-keys/{id}
Response (200 OK):
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"name": "production-app",
"key": "",
"key_preview": "sk-...01",
"tokens_used": 125000,
"last_used_at": "2025-01-15T14:22:00Z",
"created_at": "2025-01-15T10:30:00Z",
"rate_limit_rps": 5.0,
"rate_limit_burst": 10
}Endpoint: PUT /api/virtual-keys/{id}
Request Body:
{
"name": "production-app-v2",
"rate_limit_rps": 10.0,
"rate_limit_burst": 20
}Response (200 OK):
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"name": "production-app-v2",
"key": "",
"key_preview": "sk-...01",
"tokens_used": 125000,
"last_used_at": "2025-01-15T14:22:00Z",
"created_at": "2025-01-15T10:30:00Z",
"rate_limit_rps": 10.0,
"rate_limit_burst": 20
}Endpoint: DELETE /api/virtual-keys/{id}
Response: 204 No Content (empty body)
β οΈ Permanent Deletion: Keys are permanently deleted, not disabled. There is no "revoke" or "disable" endpoint. Once deleted:
- The key hash is removed from the database immediately
- Any subsequent request using that key receives
401 Unauthorized- Historical logs retain the
virtual_key_namefor auditing- Recovery is impossible - create a new key if needed
Each virtual key has an independent token bucket rate limiter.
| Setting | Default | Description |
|---|---|---|
rate_limit_enabled |
true |
Runtime toggle (DB setting) |
rate_limit_rps |
10 |
Requests per second (global default) |
rate_limit_burst |
20 |
Maximum burst size (global default) |
Virtual keys can override global rate limits via rate_limit_rps and rate_limit_burst columns:
-
null: Use global settings fromsettingstable -
0for RPS: Unlimited requests (no rate limiting for this key) -
0for burst: Invalid - rejected on creation/update (must be β₯ 1)
When a key exceeds its rate limit:
HTTP/1.1 429 Too Many Requests
Retry-After: 2
X-RateLimit-Limit: 10
X-RateLimit-Remaining: 0
X-RateLimit-Burst: 20
Content-Type: application/json
{
"error": {
"message": "Rate limit exceeded",
"type": "rate_limit_error",
"code": 429
}
}
- Stale buckets: Automatically removed after 10 minutes of inactivity
- Disable β Re-enable: All buckets reset when rate limiting is re-enabled at runtime
Token usage is tracked per virtual key:
func (r *Repository) AddTokens(ctx context.Context, keyHash string, tokens int) error {
_, err := r.pool.Exec(ctx,
`UPDATE virtual_keys SET tokens_used = tokens_used + $1, last_used_at = now() WHERE key_hash = $2`,
tokens, keyHash)
return err
}- When: After successful proxy request completion
-
What:
prompt_tokens + completion_tokensfrom provider response - How: Async fire-and-forget with 5-second timeout
- Accuracy: Best-effort tally - may lag behind actual usage
Updated on every authenticated request:
func (r *Repository) TouchLastUsed(ctx context.Context, keyHash string) error {
_, err := r.pool.Exec(ctx,
`UPDATE virtual_keys SET last_used_at = now() WHERE key_hash = $1`,
keyHash)
return err
}-
When: During
ProxyKeyMiddleware(async, non-blocking) - Timeout: 5 seconds (prevents blocking proxy path)
- Purpose: Identify active vs. dormant keys for cleanup
# Set virtual key
export PROXY_KEY="sk-a1b2c3d4e5f6789012345678abcdef01"
# List available models
curl http://localhost:8081/v1/models \
-H "Authorization: Bearer $PROXY_KEY"
# Chat completion
curl -X POST http://localhost:8081/v1/chat/completions \
-H "Authorization: Bearer $PROXY_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "hotel/gpt-4o",
"messages": [{"role": "user", "content": "Hello!"}]
}'import openai
client = openai.OpenAI(
base_url="http://localhost:8081/v1",
api_key="sk-a1b2c3d4e5f6789012345678abcdef01"
)
response = client.chat.completions.create(
model="hotel/gpt-4o",
messages=[{"role": "user", "content": "Hello!"}]
)
print(response.choices[0].message.content)import OpenAI from 'openai';
const client = new OpenAI({
baseURL: 'http://localhost:8081/v1',
apiKey: 'sk-a1b2c3d4e5f6789012345678abcdef01'
});
const response = await client.chat.completions.create({
model: 'hotel/gpt-4o',
messages: [{ role: 'user', content: 'Hello!' }]
});
console.log(response.choices[0].message.content);Admin Dashboard / API
β
βΌ
virtualkey.Generate() β "sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
β
βΌ
virtualkey.Hash() β SHA-256 (64 hex chars)
β
βΌ
INSERT INTO virtual_keys (name, key_hash, key_preview, ...)
β
βΌ
Return plaintext key ONCE to user
HTTP Request with Bearer token
β
βΌ
Extract token from "Authorization: Bearer <token>"
β
βΌ
Compute SHA-256 hash
β
βΌ
SELECT * FROM virtual_keys WHERE key_hash = $1
β
βββ NOT FOUND β 401 Unauthorized
β
βββ FOUND β Set context, continue to proxy
- Create new virtual key via API/dashboard
- Update client applications with new key
- Monitor usage of old key (via
last_used_at) - Delete old key when no longer needed
DELETE FROM virtual_keys WHERE id = $1
β
βΌ
Key hash removed from database
β
βΌ
Subsequent requests with old key β 401 Unauthorized
β
βΌ
Historical logs retain virtual_key_name for audit
| Property | Implementation |
|---|---|
| Storage | SHA-256 hash only - raw key never persisted |
| Key format |
sk- + 32 hex chars (128 bits entropy) |
| Key preview | First 3 + last 2 chars stored as key_preview (e.g., sk-...01) |
| Deletion | Permanent - DELETE removes key entirely |
| Per-key tracking | Token usage logged per virtual key |
| Rate limiting | Independent token bucket per key |
| Audit trail | Logs retain virtual_key_name after deletion |
Virtual Keys page showing all keys with name, preview, tokens used, last used timestamp, and delete button

Key creation dialog - the plaintext key is shown only once. Copy it immediately.
Symptoms: Requests rejected with Invalid virtual key
Causes:
- Key was deleted from database
- Typo in key value
- Missing
Bearerprefix in Authorization header - Key was never created (check creation response)
Resolution:
- Verify key exists:
GET /api/virtual-keys - Check
key_previewmatches your key's last 4 chars - Ensure header format:
Authorization: Bearer sk-...
Symptoms: Rate limit exceeded with Retry-After header
Resolution:
- Check per-key limits:
GET /api/virtual-keys/{id} - Increase
rate_limit_rpsor set to0for unlimited - Increase
rate_limit_burstfor traffic spikes - Wait for
Retry-Afterseconds before retrying
Symptoms: Plaintext key not saved; only preview available
Resolution:
- Impossible to recover - this is by design
- Create a new key and update clients
- Delete the old key once migrated
- Security - How provider keys are encrypted and managed
- Configuration - Global and per-key rate limit configuration
- Request Logging - How virtual keys appear in audit logs
Last synced from hugalafutro/model-hotel@b0da0d8 on 2026-07-08 23:42 UTC. Edit these pages under wiki/ (and images under docs/screenshots/) in the main repo, not here.
- π¨ Home
- βοΈ Configuration
- π Development
- π Virtual Keys
- π₯ Multi-User
- π API Reference
- π Request Logging
- π Model Discovery
- π Failover and Hotel Routing
- π Alerting
- π¨ High Availability