Security is paramount. API keys are crown jewels. This document details StackDock's security architecture and best practices.
- Threat Model
- Encryption
- Provisioning Credential Security
- Authentication & Authorization
- RBAC Enforcement
- Audit Logging
- Network Security
- Data Protection
- Security Best Practices
- Vulnerability Reporting
-
API Keys (CROWN JEWELS)
- GridPane, Vercel, AWS, DigitalOcean, etc.
- Full infrastructure access
- Highest priority
-
User Data
- Email addresses
- Names
- Organization memberships
-
Resource Metadata
- Server IPs
- Domain names
- Application URLs
-
Audit Logs
- Who did what
- Historical actions
- Compliance data
| Attack | Risk | Mitigation |
|---|---|---|
| API Key Exposure | Critical | AES-256-GCM encryption, never sent to client |
| Horizontal Privilege Escalation | High | RBAC checks on every operation |
| XSS Attacks | High | CSP headers, React auto-escaping |
| CSRF Attacks | Medium | Clerk JWT validation |
| SQL Injection | N/A | Using Convex (not SQL) |
| Compromised User Account | High | MFA, session management |
| Insider Threat | Medium | Audit logs, RBAC, encryption |
Why AES-256-GCM?
- AES-256: Industry standard, FIPS approved
- GCM Mode: Provides both confidentiality and authenticity
- Authenticated Encryption: Prevents tampering
// convex/lib/encryption.ts
import { webcrypto } from "crypto"
const MASTER_KEY = process.env.ENCRYPTION_MASTER_KEY! // 64-char hex (256 bits)
export async function encryptApiKey(plaintext: string): Promise<Uint8Array> {
const encoder = new TextEncoder()
const data = encoder.encode(plaintext)
// Generate random 96-bit IV (12 bytes)
const iv = webcrypto.getRandomValues(new Uint8Array(12))
// Import master key
const keyData = Buffer.from(MASTER_KEY, 'hex')
const key = await webcrypto.subtle.importKey(
'raw',
keyData,
{ name: 'AES-GCM', length: 256 },
false,
['encrypt']
)
// Encrypt with authenticated encryption
const encrypted = await webcrypto.subtle.encrypt(
{ name: 'AES-GCM', iv },
key,
data
)
// Combine IV + ciphertext (IV needed for decryption)
const result = new Uint8Array(iv.length + encrypted.byteLength)
result.set(iv, 0)
result.set(new Uint8Array(encrypted), iv.length)
return result
}
export async function decryptApiKey(encrypted: Uint8Array): Promise<string> {
// Extract IV (first 12 bytes)
const iv = encrypted.slice(0, 12)
const ciphertext = encrypted.slice(12)
// Import master key
const keyData = Buffer.from(MASTER_KEY, 'hex')
const key = await webcrypto.subtle.importKey(
'raw',
keyData,
{ name: 'AES-GCM', length: 256 },
false,
['decrypt']
)
// Decrypt
const decrypted = await webcrypto.subtle.decrypt(
{ name: 'AES-GCM', iv },
key,
ciphertext
)
const decoder = new TextDecoder()
return decoder.decode(decrypted)
}# Run this once to generate master key
node scripts/generate-encryption-key.js
# Output: 64-character hex string
# Add to .env.local as ENCRYPTION_MASTER_KEYgenerate-encryption-key.js:
const crypto = require('crypto')
// Generate 256-bit (32 byte) key
const key = crypto.randomBytes(32).toString('hex')
console.log('ENCRYPTION_MASTER_KEY=' + key)Development:
# .env.local
ENCRYPTION_MASTER_KEY=<64-char-hex>Production:
- Store in Convex environment variables
- Use secrets manager (AWS Secrets Manager, Doppler, etc.)
- NEVER commit to git
- NEVER log the key
When to rotate:
- Every 90 days (best practice)
- On suspected compromise
- When team member leaves
How to rotate:
- Generate new key:
node scripts/generate-encryption-key.js - Add
ENCRYPTION_MASTER_KEY_V2to environment - Update encryption function to use version field:
docks: defineTable({ encryptedApiKey: v.bytes(), keyVersion: v.number(), // Track which key was used })
- Background job re-encrypts all docks with new key
- Remove old key after re-encryption complete
| Data | Encrypted? | Storage |
|---|---|---|
| API keys (docks) | ✅ | Convex docks.encryptedApiKey |
| Provisioning credentials | ✅ | Convex docks.provisioningCredentials |
| User passwords | ✅ (Clerk) | Clerk (not in our DB) |
| User emails | ❌ (searchable) | Convex users.email |
| Resource metadata | ❌ (queryable) | Convex (servers, webServices, etc.) |
| Audit logs | ❌ (queryable) | Convex auditLogs |
-
NEVER send API keys to client
// ❌ BAD export const getDock = query({ handler: async (ctx, args) => { const dock = await ctx.db.get(args.dockId) return dock // Includes encryptedApiKey! } }) // ✅ GOOD export const getDock = query({ handler: async (ctx, args) => { const dock = await ctx.db.get(args.dockId) const { encryptedApiKey, ...safeDock } = dock return safeDock } })
-
Only decrypt in Convex server functions
// ✅ Correct: Decryption in internalMutation export const syncDock = internalMutation({ handler: async (ctx, args) => { const dock = await ctx.db.get(args.dockId) const apiKey = await decryptApiKey(dock.encryptedApiKey) // Use apiKey to call provider API } })
-
NEVER log decrypted values
// ❌ NEVER DO THIS console.log('API key:', apiKey) // ✅ Safe logging console.log('API key length:', apiKey.length) console.log('API key prefix:', apiKey.substring(0, 4) + '****')
To prevent accidental exposure of plaintext API keys, StackDock uses branded types (also called phantom types or nominal types) to enforce encryption at compile time.
Branded types add a compile-time marker to a base type to make it incompatible with other values of the same base type:
// Without branded types (unsafe)
const apiKey: string = "secret-key-123"
await ctx.db.insert("docks", { encryptedApiKey: apiKey }) // Oops! Plaintext stored!
// With branded types (type-safe)
const plaintext: PlaintextApiKey = toPlaintextApiKey("secret-key-123")
const encrypted: EncryptedApiKey = await encryptApiKey(plaintext)
await ctx.db.insert("docks", { encryptedApiKey: encrypted }) // Type-safe!// convex/lib/encryption.ts
/**
* Plaintext API key (unencrypted)
* Must be encrypted before storage
*/
export type PlaintextApiKey = string & { readonly __brand: 'PlaintextApiKey' }
/**
* Encrypted API key (ciphertext)
* Safe to store in database
*/
export type EncryptedApiKey = ArrayBuffer & { readonly __brand: 'EncryptedApiKey' }1. Converting user input to PlaintextApiKey:
import { toPlaintextApiKey } from "../lib/encryption"
export const createDock = mutation({
args: {
apiKey: v.string(), // User input
// ...
},
handler: async (ctx, args) => {
// Convert to branded type
const plaintext = toPlaintextApiKey(args.apiKey)
// Encrypt (only accepts PlaintextApiKey)
const encrypted = await encryptApiKey(plaintext)
// Store (only accepts EncryptedApiKey)
await ctx.db.insert("docks", {
encryptedApiKey: encrypted,
// ...
})
}
})2. Decrypting for use:
import { toEncryptedApiKey, decryptApiKey } from "../lib/encryption"
export const syncDock = internalMutation({
handler: async (ctx, args) => {
const dock = await ctx.db.get(args.dockId)
// Convert to branded type (optional, for clarity)
const encrypted = toEncryptedApiKey(dock.encryptedApiKey)
// Decrypt (returns PlaintextApiKey)
const plaintext = await decryptApiKey(encrypted, ctx, {
dockId: dock._id,
orgId: dock.orgId,
})
// Use plaintext (server-side only!)
await fetch(providerApi, {
headers: { Authorization: `Bearer ${plaintext}` }
})
}
})- Compile-time safety: TypeScript catches misuse before runtime
- Self-documenting: Types make it clear what's encrypted vs plaintext
- Forced conversions: Must use encryption functions to convert types
- IDE support: Better autocomplete and error messages
- Refactoring: Easy to find all encryption/decryption points
The encryption functions maintain backward compatibility using function overloads:
// New code (type-safe)
const encrypted = await encryptApiKey(plaintext as PlaintextApiKey)
// Existing code (still works)
const encrypted = await encryptApiKey("my-api-key-123")Follow these steps when adding a new secret field to the schema:
Step 1: Define the field in schema
// convex/schema.ts
myTable: defineTable({
// ...
encryptedSecret: v.bytes(),
// ...
})Step 2: Create encryption/decryption helpers (if needed)
// convex/lib/encryption.ts
// Reuse existing functions or create specialized versions
export async function encryptSecret(plaintext: PlaintextApiKey): Promise<EncryptedApiKey> {
return encryptApiKey(plaintext) // Reuse existing encryption
}
export async function decryptSecret(
encrypted: EncryptedApiKey,
ctx?: any,
auditMetadata?: { resourceId?: any; orgId?: any }
): Promise<PlaintextApiKey> {
return decryptApiKey(encrypted, ctx, auditMetadata)
}Step 3: Use branded types in mutations
// convex/myTable/mutations.ts
import { toPlaintextApiKey, encryptSecret } from "../lib/encryption"
export const createResource = mutation({
args: {
secret: v.string(),
// ...
},
handler: withRBAC("resources:full")(async (ctx, args, user) => {
// Convert to branded type
const plaintext = toPlaintextApiKey(args.secret)
// Encrypt
const encrypted = await encryptSecret(plaintext)
// Store
await ctx.db.insert("myTable", {
encryptedSecret: encrypted,
// ...
})
})
})Step 4: Add audit logging
// When decrypting
const plaintext = await decryptSecret(resource.encryptedSecret, ctx, {
resourceId: resource._id,
orgId: resource.orgId,
})
// Audit log is automatically created by decryptApiKeyStep 5: Document usage
- Update this security guide
- Add comments in schema
- Add examples in mutation files
Security Checklist for New Secret Fields:
- Field stored as
v.bytes()in schema - User input converted to
PlaintextApiKeybefore encryption - Encrypted with
encryptApiKey()or specialized function - Decryption includes audit logging
- Never sent to client in queries
- Never logged in plaintext
- Type-safe with branded types
Provisioning credentials (AWS keys, Cloudflare tokens, etc.) are used to provision infrastructure resources via StackDock's provisioning engine. These credentials are CROWN JEWELS and require the same level of security as dock API keys.
Reuse Existing Encryption: Provisioning credentials use the same encryptApiKey() function as dock API keys.
// convex/lib/encryption.ts
import { encryptApiKey } from "../lib/encryption"
// Encrypt provisioning credentials before storage
const encryptedCredentials = await encryptApiKey(plaintextCredentials)
await ctx.db.patch(dockId, {
provisioningCredentials: encryptedCredentials,
})Why Reuse?
- Same AES-256-GCM algorithm (secure)
- Same master key management
- Same security guarantees
- Simpler codebase (no duplicate encryption logic)
Convex Docks Table: Provisioning credentials stored in docks.provisioningCredentials field.
// convex/schema.ts
docks: defineTable({
orgId: v.id("organizations"),
name: v.string(),
provider: v.string(),
encryptedApiKey: v.bytes(), // Sync credentials
provisioningCredentials: v.optional(v.bytes()), // Provisioning credentials
// ...
})Why Docks Table?
- Consistent with existing dock pattern
- Same RBAC checks (docks:full for creation, provisioning:full for use)
- Same encryption lifecycle
- Unified credential management
Permission Required: provisioning:full
// convex/docks/mutations.ts
export const provisionResource = mutation({
handler: async (ctx, args) => {
const user = await getCurrentUser(ctx)
// Check provisioning:full permission
const hasPermission = await checkPermission(
ctx,
user._id,
dock.orgId,
"provisioning:full"
)
if (!hasPermission) {
throw new ConvexError("Permission denied: provisioning:full required")
}
// Proceed with provisioning...
}
})Why Separate Permission?
- Provisioning is more dangerous than operations (creates billable resources)
- Fine-grained access control (some users can view but not provision)
- Compliance requirements (who can provision resources)
When: Before storage (in Convex mutation context)
// Encrypt before storing
const encrypted = await encryptApiKey(plaintextCredentials)
await ctx.db.patch(dockId, {
provisioningCredentials: encrypted,
})Never: Expose plaintext credentials to client
Where: docks.provisioningCredentials field (encrypted bytes)
When: Created with dock or rotated via rotateProvisioningCredentials mutation
When: Only during provisioning operations (never exposed to client)
// Decrypt only in server-side Convex mutations/actions
const credentials = await decryptApiKey(
dock.provisioningCredentials,
ctx,
{ dockId: dock._id, orgId: dock.orgId }
)Security:
- Decryption only in Convex server functions
- RBAC check before decryption
- Audit logging on every decryption
Mutation: rotateProvisioningCredentials
Flow:
- Validate new credentials (test API call)
- Encrypt new credentials
- Atomically update
docks.provisioningCredentials - Audit log rotation
- Preserve old credentials on failure
// convex/docks/mutations.ts
export const rotateProvisioningCredentials = mutation({
args: {
dockId: v.id("docks"),
newCredentials: v.string(),
},
handler: async (ctx, args) => {
// 1. Get dock and check RBAC (provisioning:full)
// 2. Validate new credentials via adapter.validateCredentials()
// 3. Encrypt new credentials
// 4. Atomically update docks.provisioningCredentials
// 5. Audit log rotation
// 6. Preserve old credentials on failure
}
})Graceful Rotation:
- Validates new credentials before replacing old ones
- Old credentials preserved if validation fails
- Atomic update ensures no partial state
- Rollback preserves old credentials on unexpected errors
All Credential Operations Logged:
-
Credential Decryption (
credential.decrypt)- Logs: userId, orgId, dockId, timestamp
- Never logs: decrypted credential values
-
Credential Rotation (
credential.rotate)- Success: dockId, orgId, provider, rotatedAt
- Error: dockId, orgId, provider, errorMessage
- Never logs: oldCredentials, newCredentials
-
Provisioning Operations (
resource.provision)- Logs: userId, orgId, dockId, resourceType, resourceId
- Never logs: credentialsUsed, spec (may contain secrets)
Implementation:
// convex/lib/audit.ts
await auditLog(ctx, "credential.rotate", "success", {
dockId: dock._id,
orgId: dock.orgId,
provider: dock.provider,
rotatedAt: Date.now(),
// NEVER log: newCredentials, oldCredentials
})Flow: Provisioning credentials passed securely from Convex mutations to provisioning engine.
1. User calls provisionResource mutation
2. Mutation checks RBAC (provisioning:full)
3. Mutation decrypts provisioning credentials (server-side only)
4. Mutation calls Convex action for provisioning (internal API)
5. Action calls SST provisioning engine or dock adapter
6. Credentials cleared from memory after use
7. Audit log created (no credential values)
Security Requirements:
- ✅ Credentials never exposed to client
- ✅ Decryption only in Convex mutations (server-side)
- ✅ Internal Convex actions can receive decrypted credentials (Convex-to-Convex is secure)
- ✅ Never log credentials in plaintext
- ✅ Clear credentials from memory after use
- ✅ Use temporary credentials when possible (AWS STS assume role)
Example:
// convex/docks/mutations.ts
export const provisionResource = mutation({
handler: async (ctx, args) => {
// Decrypt credentials (never exposed to client)
const credentials = await decryptApiKey(
dock.provisioningCredentials,
ctx,
{ dockId: dock._id, orgId: dock.orgId }
)
// Pass to Convex action (internal, secure)
await ctx.runAction(internal.docks.actions.provisionResource, {
credentials, // Safe: Convex-to-Convex communication
spec: args.spec,
})
// Credentials cleared from memory after use
}
})-
Never Log Credentials
// ❌ NEVER DO THIS await auditLog(ctx, "credential.rotate", "success", { newCredentials: args.newCredentials, // EXPOSES CREDENTIALS! }) // ✅ Safe logging await auditLog(ctx, "credential.rotate", "success", { dockId: dock._id, provider: dock.provider, rotatedAt: Date.now(), })
-
Always Encrypt Before Storage
// ✅ Correct const encrypted = await encryptApiKey(plaintextCredentials) await ctx.db.patch(dockId, { provisioningCredentials: encrypted }) // ❌ Wrong await ctx.db.patch(dockId, { provisioningCredentials: plaintextCredentials })
-
Always Check RBAC Before Decryption
// ✅ Correct const hasPermission = await checkPermission(ctx, user._id, orgId, "provisioning:full") if (!hasPermission) throw new ConvexError("Permission denied") const credentials = await decryptApiKey(dock.provisioningCredentials, ctx, {...}) // ❌ Wrong (no RBAC check) const credentials = await decryptApiKey(dock.provisioningCredentials)
-
Rotate Credentials Regularly
- Best practice: Every 90 days
- On suspected compromise
- When team member leaves
- Use
rotateProvisioningCredentialsmutation
-
Use Audit Logs for Compliance
- All credential operations logged
- Query audit logs:
by_org,by_user,by_resource - Review logs weekly for suspicious activity
When to Rotate:
- Every 90 days (recommended)
- On suspected compromise
- When team member with access leaves
- When provider requires rotation
How to Rotate:
// Use rotateProvisioningCredentials mutation
await convex.mutation(api.docks.mutations.rotateProvisioningCredentials, {
dockId: dock._id,
newCredentials: newPlaintextCredentials,
})Graceful Rotation Process:
- Validate new credentials before replacing
- Test new credentials with provider API
- Only replace after successful validation
- Preserve old credentials on failure
- Audit log all rotation attempts
Rollback on Failure:
- If validation fails: Old credentials preserved (never replaced)
- If encryption fails: Old credentials preserved (never replaced)
- If database update fails: Old credentials preserved (never replaced)
- Clear error messages: "Old credentials have been preserved"
- All provisioning credentials encrypted (AES-256-GCM)
- RBAC checks enforce
provisioning:fullpermission - Audit logging enabled for all credential operations
- Credentials never logged in plaintext
- Credentials never exposed to client
- Credential rotation mutation implemented
- Graceful rotation logic (validate before replace)
- Old credentials preserved on rotation failure
- Secure credential passing to provisioning engine
- Credentials cleared from memory after use
JWT-Based Authentication:
// Clerk issues JWT on login
// JWT contains:
{
sub: "user_abc123", // Clerk user ID
iss: "clerk",
exp: 1234567890, // Expiration
iat: 1234567890, // Issued at
}
// Convex validates JWT on every request
const identity = await ctx.auth.getUserIdentity()
// identity.subject = "user_abc123"User Sync (Webhook):
// src/routes/api/webhooks/clerk.ts
export async function POST(request: Request) {
const payload = await request.text()
const headers = Object.fromEntries(request.headers)
// Verify webhook signature (CRITICAL)
const wh = new Webhook(process.env.CLERK_WEBHOOK_SECRET!)
try {
const evt = wh.verify(payload, headers)
if (evt.type === 'user.created' || evt.type === 'user.updated') {
// Sync to Convex users table
await convex.mutation(api.users.syncFromClerk, {
clerkId: evt.data.id,
name: evt.data.first_name + ' ' + evt.data.last_name,
email: evt.data.email_addresses[0].email_address,
})
}
return new Response('OK', { status: 200 })
} catch (err) {
console.error('Webhook verification failed:', err)
return new Response('Invalid signature', { status: 400 })
}
}See RBAC.md for complete details.
Zero-Trust Model:
- Every operation validates permissions
- No assumptions about user access
- Defense in depth
// convex/lib/rbac.ts
export function withRBAC(permission: string) {
return (handler: any) => async (ctx: MutationCtx, args: any) => {
// 1. Get current user
const user = await getCurrentUser(ctx)
if (!user) throw new ConvexError("Not authenticated")
// 2. Check permission
const hasPermission = await checkPermission(
ctx,
user._id,
args.orgId,
permission
)
if (!hasPermission) {
// Log denial
await auditLog(ctx, "rbac.deny", "error", {
permission,
userId: user._id,
orgId: args.orgId,
})
throw new ConvexError(`Permission denied: ${permission}`)
}
// 3. Log grant
await auditLog(ctx, "rbac.grant", "success", { permission })
// 4. Execute handler
return handler(ctx, args, user)
}
}// convex/docks/mutations.ts
export const createDock = mutation({
args: {
orgId: v.id("organizations"),
provider: v.string(),
name: v.string(),
apiKey: v.string(),
},
handler: withRBAC("docks:full")(async (ctx, args, user) => {
// User has been validated
// Permission has been checked
// Safe to proceed
const encrypted = await encryptApiKey(args.apiKey)
return await ctx.db.insert("docks", {
orgId: args.orgId,
provider: args.provider,
name: args.name,
encryptedApiKey: encrypted,
lastSyncStatus: "pending",
})
}),
})Every query filters by orgId:
export const listServers = query({
args: { orgId: v.id("organizations") },
handler: async (ctx, args) => {
const user = await getCurrentUser(ctx)
// Verify user belongs to org
const membership = await ctx.db
.query("memberships")
.withIndex("by_org_user", q =>
q.eq("orgId", args.orgId).eq("userId", user._id)
)
.first()
if (!membership) {
throw new ConvexError("Not a member of organization")
}
// Only return org's servers (no cross-org leakage)
return await ctx.db
.query("servers")
.withIndex("by_orgId", q => q.eq("orgId", args.orgId))
.collect()
},
})- All mutations (create, update, delete)
- RBAC decisions (granted/denied with reason)
- Authentication events (login, logout, failed attempts)
- Dock syncs (success/failure)
- Security events (permission changes, key rotation)
auditLogs: defineTable({
orgId: v.id("organizations"),
userId: v.id("users"),
action: v.string(), // "dock.create", "rbac.deny", etc.
resourceType: v.optional(v.string()),
resourceId: v.optional(v.string()),
metadata: v.any(), // Action-specific data
result: v.union(v.literal("success"), v.literal("error")),
errorMessage: v.optional(v.string()),
timestamp: v.number(),
ipAddress: v.optional(v.string()),
userAgent: v.optional(v.string()),
})
.index("by_org", ["orgId", "timestamp"])
.index("by_user", ["userId", "timestamp"])
.index("by_resource", ["resourceType", "resourceId"])// convex/lib/audit.ts
export async function auditLog(
ctx: MutationCtx,
action: string,
result: "success" | "error",
metadata?: Record<string, any>
) {
const user = await getCurrentUser(ctx)
await ctx.db.insert("auditLogs", {
orgId: user.defaultOrgId!,
userId: user._id,
action,
result,
metadata: metadata || {},
timestamp: Date.now(),
})
}export const createDock = mutation({
handler: withRBAC("docks:full")(async (ctx, args, user) => {
try {
const dockId = await ctx.db.insert("docks", { ... })
// Log success
await auditLog(ctx, "dock.create", "success", {
dockId,
provider: args.provider,
})
return dockId
} catch (error) {
// Log failure
await auditLog(ctx, "dock.create", "error", {
error: error.message,
provider: args.provider,
})
throw error
}
}),
})// Admin can view all logs for org
export const getAuditLogs = query({
args: { orgId: v.id("organizations"), limit: v.optional(v.number()) },
handler: withRBAC("settings:read")(async (ctx, args) => {
return await ctx.db
.query("auditLogs")
.withIndex("by_org", q => q.eq("orgId", args.orgId))
.order("desc")
.take(args.limit || 100)
}),
})Production: Enforce HTTPS
// middleware.ts
if (process.env.NODE_ENV === 'production' && !request.url.startsWith('https://')) {
return Response.redirect('https://' + request.url.substring(7))
}// app.config.ts
export default defineConfig({
server: {
headers: {
'Content-Security-Policy': [
"default-src 'self'",
"script-src 'self' 'unsafe-inline' https://clerk.com https://convex.cloud",
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: https:",
"connect-src 'self' https://clerk.com https://*.convex.cloud",
"frame-ancestors 'none'",
].join('; '),
'X-Frame-Options': 'DENY',
'X-Content-Type-Options': 'nosniff',
'X-XSS-Protection': '1; mode=block',
'Referrer-Policy': 'strict-origin-when-cross-origin',
},
},
})Convex: Whitelist allowed origins
// convex.json
{
"functions": "convex/",
"cors": {
"allowedOrigins": [
"http://localhost:3000",
"https://app.stackdock.dev"
]
}
}Convex Built-In:
- 1000 requests/second per deployment
- Automatic throttling
Custom Rate Limiting (per user):
const rateLimits = new Map<string, number[]>()
export const createDock = mutation({
handler: withRBAC("docks:full")(async (ctx, args, user) => {
// Check rate limit (5 docks per minute)
const userId = user._id
const now = Date.now()
const userRequests = rateLimits.get(userId) || []
// Remove requests older than 1 minute
const recentRequests = userRequests.filter(time => now - time < 60000)
if (recentRequests.length >= 5) {
throw new ConvexError("Rate limit exceeded. Please try again later.")
}
recentRequests.push(now)
rateLimits.set(userId, recentRequests)
// Proceed with creation
// ...
}),
})- Encrypted: API keys (AES-256-GCM)
- Unencrypted: User data, resource metadata (queryable)
- Handled by Convex: Database encryption at rest
- HTTPS: All communication encrypted (TLS 1.3)
- Clerk: JWT tokens (signed and verified)
- Convex: WebSocket over TLS
User Requests Deletion:
export const deleteUserData = mutation({
handler: async (ctx, args) => {
const user = await getCurrentUser(ctx)
// Delete user's data
await ctx.db.delete(user._id)
// Cascade delete memberships
const memberships = await ctx.db
.query("memberships")
.withIndex("by_userId", q => q.eq("userId", user._id))
.collect()
for (const membership of memberships) {
await ctx.db.delete(membership._id)
}
// Audit log deletion
await auditLog(ctx, "user.delete", "success", { userId: user._id })
},
})- Convex Automatic Backups: Daily snapshots
- Export Critical Data: Weekly exports to S3
- Disaster Recovery: RPO <24 hours, RTO <4 hours
-
Always use RBAC middleware
export const myMutation = mutation({ handler: withRBAC("resource:action")(async (ctx, args) => { // Your logic }), })
-
Never expose sensitive data
// Remove encryptedApiKey before returning const { encryptedApiKey, ...safeDock } = dock return safeDock
-
Validate all inputs
if (!isValidProviderName(args.provider)) { throw new ConvexError("Invalid provider name") }
-
Log security events
await auditLog(ctx, "security.event", "success", { ... })
-
Handle errors securely
// ❌ Don't expose internal details throw new Error(`Database error: ${dbError.message}`) // ✅ Generic error messages throw new ConvexError("Unable to create dock. Please try again.")
- Rotate encryption keys quarterly
- Rotate provisioning credentials every 90 days
- Review audit logs weekly
- Monitor failed auth attempts
- Monitor credential rotation events
- Update dependencies monthly
- Security audit annually
DO NOT create a public GitHub issue for security vulnerabilities.
Email: security@stackdock.dev
Include:
- Description of vulnerability
- Steps to reproduce
- Potential impact
- Suggested fix (if any)
- 24 hours: Acknowledge receipt
- 7 days: Triage and assess severity
- 30 days: Fix and deploy patch
- Public disclosure: After patch deployed (coordinated)
Coming Soon: Bug bounty program details
- All API keys encrypted
- RBAC enforced on all mutations
- Audit logging enabled
- CSP headers configured
- HTTPS enforced
- Webhook signatures verified
- Rate limiting implemented
- Error messages don't expose internals
- No console.log of sensitive data
- Dependencies up to date
- Security scan passed
- Rotate encryption keys (90 days)
- Rotate provisioning credentials (90 days)
- Review audit logs (weekly)
- Monitor credential rotation events
- Update dependencies (monthly)
- Security audit (annually)
- Penetration test (annually)
Security is everyone's responsibility. When in doubt, ask.
Contact: security@stackdock.dev