Encryption
Pixelflare supports AES-256-GCM encryption at rest for images that require privacy and access control. When you upload an image with encryption enabled, it gets encrypted with a unique per-image key before storage. Access is controlled through time-limited HMAC-signed tokens, which can only ever be generated by the authenticated user who uploaded the image.
When to use? This is ideal for sensitive or private content that must be protected from unauthorized access, even if storage is compromised. But it comes with trade-offs in performance, usability, and cost. So it shouldn't be used for images which need to be publicly accessible or easily embeddable. The decryption process adds latency, tokens expire and certain CDN features (like edge caching, transformations, etc) are not available. So, use encryption only when security benefits outweigh operational overhead.
How It Works
Pixelflare uses a two-tier key hierarchy with AES-256-GCM authenticated encryption.
graph TB
RootKey[ENCRYPTION_ROOT_KEY<br/>Environment Variable]
TMK[TMK - Tenant Master Key<br/>Per-account, versioned<br/>Stored in KV wrapped]
CEK1[CEK - Content Encryption Key<br/>Per-image unique key]
CEK2[CEK - Content Encryption Key<br/>Per-image unique key]
CEK3[CEK - Content Encryption Key<br/>Per-image unique key]
Image1[Encrypted Image 1]
Image2[Encrypted Image 2]
Image3[Encrypted Image 3]
RootKey -->|Wraps| TMK
TMK -->|Wraps| CEK1
TMK -->|Wraps| CEK2
TMK -->|Wraps| CEK3
CEK1 -->|Encrypts| Image1
CEK2 -->|Encrypts| Image2
CEK3 -->|Encrypts| Image3Root Key - Set via ENCRYPTION_ROOT_KEY environment variable, used to wrap TMKs.
TMK (Tenant Master Key) - Generated when you enable encryption. Each account gets a unique 256-bit key stored encrypted in KV. You can rotate this to create new versions (v1, v2, etc). Old images keep working with their original version.
CEK (Content Encryption Key) - Every encrypted image gets a unique 256-bit key. This key is wrapped (encrypted) with your TMK and stored in the image metadata. The CEK encrypts the actual image bytes.
This hierarchy means if storage is compromised, images cannot be decrypted without access to your TMK, which is itself wrapped under the root key.
Encryption and Decryption
Encryption Flow
When you upload an image with encryption enabled:
sequenceDiagram
participant User
participant API
participant Crypto
participant KV
participant R2
participant DB
User->>API: Upload image (encrypt=true)
API->>Crypto: Generate random CEK (32 bytes)
API->>KV: Fetch TMK for user
KV-->>API: Return wrapped TMK
API->>Crypto: Unwrap TMK with root key
API->>Crypto: Wrap CEK with TMK (+ AAD)
API->>Crypto: Encrypt image with CEK
API->>R2: Store encrypted bytes + metadata
API->>DB: Mark image as encrypted (kver)
API->>DB: Log crypto event
API-->>User: Upload completeStep-by-step:
- You enable encryption in upload settings
- API generates a random 32-byte CEK
- API fetches your TMK from KV (creates TMK v1 if first time)
- TMK is unwrapped using the root key
- CEK is wrapped using TMK with AAD (owner, image ID, variant)
- Image bytes are encrypted with CEK using AES-GCM (produces IV and auth tag)
- Encrypted bytes stored in R2 with metadata containing:
- Wrapped CEK
- IV (12 bytes)
- TMK version used
- AAD
- Content type
- Database records
is_encrypted=1andkey_version - Event logged to
crypto_eventsaudit table
Decryption Flow
Encrypted images require time-limited access tokens to view or download.
sequenceDiagram
participant User
participant Frontend
participant API
participant Crypto
participant KV
participant R2
User->>Frontend: View encrypted image page
Frontend->>API: Generate token (5 min preview)
API->>Crypto: Create HMAC-signed token
API-->>Frontend: Return token URL
Frontend->>User: Display with token URL
User->>Frontend: Click "Generate Access Token"
Frontend->>API: Generate token (custom expiry)
API->>Crypto: Create HMAC-signed token
API-->>Frontend: Return token URL + expiry
Frontend->>User: Show token for sharing
User->>API: Request image with token
API->>Crypto: Verify HMAC + expiry
API->>KV: Fetch TMK (version from token)
API->>R2: Fetch encrypted image + metadata
API->>Crypto: Unwrap CEK with TMK
API->>Crypto: Decrypt image with CEK
API-->>User: Serve decrypted imageToken Types:
Auto-preview tokens - Frontend auto-generates 5-minute tokens when you view an encrypted image page. These let you preview the image in the UI without manual token generation.
Manual tokens - You explicitly generate tokens (5 minutes to 90 days) for sharing, downloading, or embedding. Only these activate the download button, copy URL, and embed codes.
Token Format:
Tokens are HMAC-based (not JWTs):
{imageId}|{variant}|{expiresAt}|{kver}|{hmac}imageId- Image identifiervariant- Empty string for original, or variant nameexpiresAt- Unix timestamp (seconds)kver- TMK version to usehmac- Base64url HMAC-SHA256 signature
The HMAC uses UPLOAD_TOKEN_SECRET as the signing key. Tokens cannot be forged without this secret.
Decryption Process:
- User requests image with token parameter:
/i/{imageId}?t={token} - API verifies HMAC signature matches
- API checks token has not expired
- API fetches image metadata from R2
- API unwraps TMK from KV using root key
- API unwraps CEK from metadata using TMK
- API decrypts image bytes using CEK
- API serves decrypted image with original content type
Key Management
Enabling Encryption
First time setup creates TMK v1:
POST /v1/encryption/enableReturns:
{
"enabled": true,
"tmk_version": 1
}It's idempotent, so safe to call that endpoint multiple times.
This can also be done via the web UI, in the Encryption Settings page.
Key Rotation
Rotate your TMK to create a new version:
POST /v1/encryption/rotateReturns:
{
"tmk_version": 2,
"rotated_at": "2025-01-15T10:30:00.000Z"
}What happens:
- New TMK generated and stored as v2
- Future encrypted images use v2
- Existing images keep working with their original version (v1)
- Both versions maintained in KV for backward compatibility
Rate limit: 5 rotations per hour.
Best practice: Rotate periodically for security hygiene (quarterly or annually).
Again, this can also be done via the web UI, in the Encryption Settings page.
Checking Status
GET /v1/encryption/statusReturns:
{
"enabled": true,
"tmk_version": 2,
"encrypted_count": 147
}Like as before, you can see this info in the Encryption Settings page
Access Tokens Generating
You can generate and preview tokens from the image detail page in the UI. But below is the API flow.
POST /v1/images/{imageId}/tokenRequest body:
{
"variant": "thumbnail",
"expires_in": 3600
}Response:
{
"token": "abc123...",
"url": "https://cdn.example.com/i/abc123?t=...",
"expires_at": "2025-01-15T11:30:00.000Z",
"variant": "thumbnail"
}Parameters:
variant- Optional, defaults to"original"expires_in- Seconds until expiry (300 to 604800)- Min: 5 minutes (300)
- Max: 7 days (604800)
- Default: 1 hour (3600)
Rate limit: 20 tokens per image per minute.
The frontend has some expiration presets, ranging from 5 minutes to a week, but in the API you can do whatever.
Offline Decryption
If you exported your image data, or enabled S3 backups, you will need to export your key bundle, so that you can decrypt any backed-up images offline. Again, this can be done via the web UI, in the Encryption Settings page, or using the API. You'll need to choose yourself a strong password. If you loose your password, you loose access to your encrypted images forever.
Exporting Key Bundle
- Endpoint:
POST /v1/encryption/bundle/export - Request body:
{ "passphrase": "your-strong-passphrase-here", "kver": 2 }
Response:
{
"v": 1,
"owner": "user123",
"kver": 2,
"alg": "AES-GCM",
"iv": "base64url-iv",
"wrapped_tmk": "base64url-encrypted-tmk",
"kdf": { "name": "PBKDF2", "iterations": 100000, "hash": "SHA-256", "salt": "base64url-salt" }
}Key Bundle Structure
The exported bundle contains your TMK encrypted with your passphrase using PBKDF2 key derivation:
- Passphrase derivation - PBKDF2-SHA256 with 100,000 iterations (OWASP 2023 minimum)
- Random salt - 16 bytes, prevents rainbow table attacks
- TMK encryption - Your TMK encrypted with the derived key using AES-GCM
- Bundle format - JSON with all parameters needed to decrypt
Security: Store the bundle and passphrase separately. If lost, encrypted backups cannot be recovered.
Usage: You need custom tooling to use the bundle for offline decryption. The bundle gives you the TMK, which can unwrap CEKs from image metadata, which can then decrypt images.
Implementation Details
Algorithms
Image Encryption: AES-256-GCM
- 256-bit keys (32 bytes)
- 96-bit IV (12 bytes)
- 128-bit authentication tag (16 bytes)
- AAD includes owner, image ID, variant
Key Wrapping: AES-256-GCM
- Same parameters as image encryption
- Used for both TMK and CEK wrapping
Access Tokens: HMAC-SHA256
- Signature prevents forgery
- Includes expiry for time-limiting
Key Bundle: PBKDF2-SHA256
- 100,000 iterations
- 128-bit salt (16 bytes)
- Derived key used for AES-GCM encryption
Key Sizes
All defined in packages/config/src/encryption.ts:
KEY_LENGTH_BYTES = 32(256-bit)IV_LENGTH_BYTES = 12(96-bit)TAG_LENGTH_BYTES = 16(128-bit)BUNDLE_SALT_LENGTH_BYTES = 16(128-bit)
Constants
The global settings for encryption are defined in config/src/encryption.ts. As of time of writing, these look like:
// Token Configuration
DEFAULT_ACCESS_TOKEN_EXPIRY_SECONDS = 3600;
MIN_ACCESS_TOKEN_EXPIRY_SECONDS = 300;
MAX_ACCESS_TOKEN_EXPIRY_SECONDS = 604800;
// TMK Configuration
TMK_KV_PREFIX = 'tmk';
DEFAULT_TMK_VERSION = 1;
MAX_TMK_VERSION = 1000;
// Bundle Configuration
BUNDLE_KDF = 'PBKDF2';
BUNDLE_KDF_ITERATIONS = 100000;
BUNDLE_KDF_HASH = 'SHA-256';
// Rate Limits
MAX_TMK_ROTATIONS_PER_HOUR = 5;
MAX_BUNDLE_EXPORTS_PER_HOUR = 10;
MAX_TOKEN_GENERATIONS_PER_IMAGE_PER_MINUTE = 20;Metadata Format
Stored with each encrypted image in R2:
{
v: 1, // Schema version
enc: "AES-GCM", // Algorithm
iv: string, // base64url IV
tag_len: 16, // Auth tag bytes
wrapped_cek: string, // base64url wrapped CEK
kver: number, // TMK version
aad: {
owner: string,
id: string,
variant: string | null
},
contentType: string // Original MIME type
}Environment Variables
ENCRYPTION_ROOT_KEY="production-key-minimum-32-bytes-required"
UPLOAD_TOKEN_SECRET="production-secret-for-hmac-signing"
API_HASH_SECRET="used-for-various-hashing-operations"
BACKUP_ENCRYPTION_KEY="used-for-encrypting-s3-backups"Generating Keys
You can make a secure random key with something like: openssl rand -base64 32
Be sure to store these super securely, as it's what's used to unwrap your TMKs.
If missing, then the encryption endpoints will just return 503 "Encryption not configured".
KV Namespace
TMKs are stored in your KV namespace with key format: tmk:{owner}:v{version}
They look like tmk:user123:v2, and are in a Base64url-encoded wrapped format of IV || ciphertext_with_tag. Note that these don't have a TTL, so they persist until rotated or deleted.
Database Schema
The schema for encryption-related stuff is in packages/database/migrations/021_encryption.sql.
It adds a crypto table, as well as makes some updates to the images table (to record key version), and the corresponding changes in user table to track that TMK version.
UI Implementation
Uploading: Encrypted images
- Flow is basically exactly the same as normal upload, user just checks the "Encrypt image" toggle
Viewing: Encrypted image
- Auto-generate 5-minute preview token on page load
- Shows a sexy decryption animation while the decryption is happening
- Let user generate their own token, with desired expiry. Once generated, users can:
- Download image
- Copy URL with token
- Get embed code with token
- See the newly decrypted image
- Note that there's no EXIF metadata, since any EXIF extraction would require authenticating to fetch the image, which would expose the encrypted content, so the ExifViewer component just skips for encrypted images, no biggie
UI file locations
src/lib/api/operations/encryption.ts- API client functionssrc/lib/components/image-details/EncryptionSection.svelte- Manual token generationsrc/lib/components/settings/EncryptionStatus.svelte- Status displaysrc/lib/components/settings/KeyRotation.svelte- TMK rotation UIsrc/lib/components/settings/ExportKeyBundle.svelte- Bundle export UIsrc/lib/components/settings/EncryptionGuide.svelte- Educational guide
User Experience Implications
As mentioned at the start, encryption comes with trade-offs. This is why it's only appropriate for sensitive images, which you're not going to want to embed anywhere.
- No CDN edge caching, because every request needs decryption. This increases latency and costs.
- Token management overhead, since you'll need to generate a token before sharing, and that token will expire
Audit and Security
Crypto Events Audit Log
All encryption operations are logged to the crypto_events table for security auditing and compliance.
Logged events:
- TMK creation and rotation
- Image encryption
- Key bundle exports
- Access token generation
Event detail structure:
{ operation: string, timestamp: string, metadata: object }Querying events:
SELECT * FROM crypto_events WHERE owner = ? ORDER BY created_at DESC;Rate Limiting
Prevents abuse of sensitive operations:
- TMK rotation: 5 per hour per user
- Bundle export: 10 per hour per user
- Token generation: 20 per minute per image
Rate limits enforced in middleware before operation execution.
Security Best Practices
Environment secrets:
- Use cryptographically random keys (32+ bytes)
- Never commit to version control
- Rotate periodically (annually minimum)
- Use Cloudflare secrets management in production
Key bundles:
- Store separately from S3 backups
- Use strong passphrases (16+ characters)
- Keep offline in secure location
- Essential for disaster recovery
Access tokens:
- Use shortest acceptable expiry
- Generate new tokens instead of reusing
- Treat as sensitive credentials
- Never log token values
TMK rotation:
- Rotate on security incidents
- Rotate on employee turnover
- Rotate periodically for hygiene
- Note that old versions will remain for backward compatibility
Monitoring:
- Review crypto event logs regularly
- Set up alert on unusual token generation patterns
- Monitor rate limit hits
- Track failed decryption attempts
File Reference
If you want to review the crypto implementation, below is the relevant files to give you a good starting point..
- API Routes:
src/routes/api/encryption.ts- HTTP endpoints
- Core Crypto:
src/lib/encryption.ts- AES-GCM, HMAC, key wrapping, base64url
- Services:
src/services/encryption/tmk.ts- TMK managementsrc/services/encryption/bundle.ts- Key bundle exportsrc/services/encryption/queries.ts- Database queriessrc/services/encryption/audit.ts- Event logging
- Schemas:
src/schemas/encryption.ts- Zod validation schemas
- Config:
src/encryption.ts- Constants and limits
- Database:
migrations/021_encryption.sql- Schema migration
- Frontend UI:
- See UI File Locations (above!)