Skip to main content

Overview

Vault attachments let you store encrypted files alongside your credentials and secure notes. Documents, recovery codes, license keys, and identity scans can be attached to any vault item. All files are encrypted client-side before upload — the server only ever stores ciphertext. The attachment system uses the same envelope encryption model as the rest of the vault: each file gets its own random AES-256 key, which is wrapped by your vault’s encKey. The server cannot decrypt attachments under any circumstances.

Encryption model

Each attachment encrypts four pieces of data independently, each with its own random 96-bit IV:
ComponentEncryptionPurpose
File contentAES-256-GCM (per-file key)The actual file bytes
FilenameAES-256-GCM (per-file key)Original filename (e.g. recovery-codes.pdf)
MIME typeAES-256-GCM (per-file key)Content type (e.g. application/pdf)
File keyAES-256-GCM (vault encKey)The per-file key itself, wrapped for storage
The filename and MIME type are encrypted because they can reveal sensitive information about the attachment’s purpose. The server stores only opaque ciphertext for all metadata fields.

Encrypted content format

The encrypted file content is stored as a single base64-encoded blob with the IV prepended:
[ 12-byte IV ][ AES-256-GCM ciphertext + 128-bit auth tag ]
This self-contained format means the IV does not need to be stored separately — it is extracted from the first 12 bytes during decryption.

Wrapped file key format

The per-file key is wrapped using AES-256-GCM with the vault’s encKey and stored as a JSON object:
{
  "iv": "<base64-encoded 12-byte IV>",
  "ciphertext": "<base64-encoded wrapped key + auth tag>"
}

Upload flow

1

Select file

The user selects a file from their device to attach to a vault item (password, note, or generic item).
2

Client-side encryption

The browser calls encryptAttachment(file, encKey), which generates a per-file AES-256 key using crypto.subtle.generateKey(), encrypts the content, filename, and MIME type, then wraps the file key with the vault’s encKey.
3

Upload encrypted payload

The client sends a POST /api/vault/attachments request containing the base64-encoded encrypted content, wrapped file key, encrypted metadata, and the parent item reference.
4

Server-side storage

The server validates the request, checks storage quotas, verifies parent item ownership, uploads the encrypted blob to Supabase Storage, and saves metadata to the vault_attachments table.
5

Audit log

The upload is recorded in the audit log with the parent item ID, type, and file size.
If the database insert fails after the file has been uploaded to storage, the server automatically cleans up the orphaned storage object to prevent storage leaks.

Download flow

1

Request attachment

The client sends GET /api/vault/attachments/{id}. The server verifies ownership and returns the encrypted content, wrapped file key, and encrypted metadata.
2

Unwrap file key

The client decrypts the wrapped file key using the vault’s encKey via crypto.subtle.decrypt(), then imports the raw key material as an AES-256-GCM CryptoKey.
3

Decrypt content

The IV is extracted from the first 12 bytes of the encrypted content. The remaining bytes are decrypted with AES-256-GCM using the unwrapped file key.
4

Decrypt metadata

The filename and MIME type are decrypted separately using the same file key, each with their own stored IV.
5

Deliver to user

A Blob is constructed with the decrypted content and the original MIME type, then delivered as a file download with the original filename.

Size limits and quotas

LimitValue
Max file size25 MB per file
Max base64 payload35,000,000 characters (~25 MB encoded)
Total storage (free tier)100 MB per user
Upload rate limit10 uploads per hour
Download rate limit30 downloads per minute
Read (list) rate limit30 requests per minute
Delete rate limit30 deletes per minute
When the total storage quota is exceeded, the upload API returns a 413 status with the current usage, limit, and remaining capacity. Delete unused attachments to free space.

Parent item types

Attachments are linked to a parent vault item. The parent_type field determines which table is queried for ownership verification:
Parent typeDatabase tableExample use case
passwordpasswordsRecovery codes, security key backups
notenotesIdentity documents, license scans
itemvault_itemsGeneric file storage
The server enforces ownership by confirming the parent item belongs to the authenticated user before accepting an upload or serving a download.

API endpoints

MethodEndpointDescription
GET/api/vault/attachments?parent_id=X&parent_type=YList attachments for a vault item
POST/api/vault/attachmentsUpload an encrypted attachment
GET/api/vault/attachments/{id}Download an encrypted attachment
DELETE/api/vault/attachments/{id}Delete an attachment

Request schema (upload)

{
  "parent_id": "uuid",
  "parent_type": "password | note | item",
  "file_name_encrypted": "<encrypted filename JSON>",
  "content_type_encrypted": "<encrypted MIME type JSON>",
  "size_bytes": 1048576,
  "wrapped_file_key": {
    "iv": "<base64>",
    "ciphertext": "<base64>"
  },
  "encryption_version": 3,
  "encrypted_content": "<base64-encoded encrypted file>"
}

Response schema (download)

{
  "id": "uuid",
  "parent_id": "uuid",
  "parent_type": "password",
  "file_name_encrypted": "<encrypted filename JSON>",
  "content_type_encrypted": "<encrypted MIME type JSON>",
  "size_bytes": 1048576,
  "wrapped_file_key": { "iv": "...", "ciphertext": "..." },
  "encryption_version": 3,
  "encrypted_content": "<base64-encoded encrypted file>",
  "created_at": "2026-03-05T12:00:00.000Z"
}

Client-side API

The vault-attachment-crypto module exposes two functions for use in the browser:
import { encryptAttachment } from "@/lib/vault-attachment-crypto"

const encrypted = await encryptAttachment(file, encKey)
// encrypted.encryptedContent   — base64 blob
// encrypted.wrappedFileKey     — { iv, ciphertext }
// encrypted.fileNameEncrypted  — encrypted filename
// encrypted.contentTypeEncrypted — encrypted MIME type
// encrypted.sizeBytes          — original file size

Security properties

  • Zero-knowledge storage: the server stores only ciphertext. File content, filenames, and MIME types are never visible server-side.
  • Per-file key isolation: compromising one file key does not expose other attachments. Each file has an independent random AES-256 key.
  • Authenticated encryption: AES-256-GCM provides both confidentiality and integrity. Any tampering with the ciphertext or IV causes decryption to fail.
  • Ownership enforcement: every API operation verifies that the authenticated user owns both the attachment and its parent item.
  • Audit trail: uploads and deletes are recorded in the audit log with parent item context and file size.
When you change your master password, the vault’s encKey is re-derived. All wrapped file keys are re-wrapped with the new encKey as part of the key rotation process. The encrypted file content itself does not need to be re-encrypted.