A self-hosted, end-to-end encrypted pastebin where the server never sees your data. All encryption and decryption happens entirely in your browser. Inspired by Mega.nz's key-in-fragment approach.
- Client generates a random 256-bit master key using
crypto.getRandomValues() - Argon2id derives an AES-256-GCM key from the master key + random 128-bit salt (t=3, m=64MB, p=1)
- Content is encrypted client-side using AES-256-GCM with a random 96-bit IV
- Only ciphertext, IV, and salt are sent to the server — the key never leaves the browser
- The master key is placed in the URL fragment (
/p/{id}#{key}). Per RFC 3986 §3.5, fragments are never transmitted in HTTP requests - The recipient's browser decodes the key from the fragment and decrypts locally
Optional password protection adds a second factor: Argon2id derives the AES key from masterKey || utf8(password). Without the correct password, decryption fails with an AES-GCM authentication error — no partial information is revealed.
| Field | Content |
|---|---|
id |
Random 128-bit URL-safe identifier |
encrypted_data |
AES-256-GCM ciphertext (base64url) |
iv |
Random 96-bit IV (base64url) |
salt |
Random 128-bit Argon2id salt (base64url) |
delete_token |
Random 192-bit token for paste deletion |
has_password |
Boolean — whether a password is required |
burn_after_read |
Boolean — delete on first access |
created_at |
Unix timestamp |
expires_at |
Optional expiry timestamp |
The server has zero ability to decrypt any paste. Even with full database access, an attacker cannot recover plaintext without the key from the URL fragment.
| Measure | Implementation |
|---|---|
| CSP with per-request nonce | script-src 'nonce-{random}' — blocks inline script injection |
Referrer-Policy: no-referrer |
Prevents key leakage via referrer headers |
X-Frame-Options: DENY |
Clickjacking protection |
X-Content-Type-Options: nosniff |
MIME sniffing protection |
Permissions-Policy |
Camera, microphone, geolocation, payment all denied |
| Rate limiting | 30 creates / 120 reads / 5 reports per 15-minute window, per IP |
| Timing-safe comparison | crypto/hmac.Equal for all token verification |
| CSRF protection | Origin vs Host check + Content-Type: application/json enforcement |
| Non-root container | Runs as uid 65532 (distroless nonroot) |
| Read-only filesystem | Container root is read-only; only data volume is writable |
| No URL fragment logging | Server only logs r.URL.Path — query strings and fragments never appear in logs |
Cache-Control: no-store |
Prevents caching of encrypted content |
- AES-256-GCM encryption — authenticated, tamper-evident
- Argon2id key derivation — GPU/ASIC resistant (t=3, m=64MB)
- Optional password — second KDF factor, never sent to server
- Burn after read — deleted server-side on first access
- Custom expiry — 10 min to 1 year, or no expiry
- Syntax highlighting — 25+ languages via highlight.js
- Markdown rendering — with DOMPurify sanitization
- Encrypted comments — E2E encrypted using the same derived key
- Paste history — local browser history (localStorage), keys never leave the device
- QR code — share pastes via QR on mobile
- Delete link — destroy a paste early via a 192-bit token
- Admin panel — report management, paste deletion
- Prometheus metrics —
/metricsendpoint with Bearer token auth - Telegram notifications — abuse reports forwarded to a Telegram bot
| Layer | Technology |
|---|---|
| Frontend | React 18, Vite 6, React Router 6 |
| Crypto (client) | Web Crypto API (AES-256-GCM), Argon2id (WASM via argon2-browser) |
| Backend | Go 1.23 |
| HTTP Router | chi v5 |
| Rate Limiting | go-chi/httprate |
| Database | SQLite via modernc.org/sqlite (pure Go, no CGo) |
| Logging | log/slog (structured JSON, stdlib) |
| Metrics | prometheus/client_golang |
| Notifications | Telegram Bot API (net/http, no external dependencies) |
| Container | Multi-stage Docker build → gcr.io/distroless/static-debian12:nonroot |
| CI/CD | GitHub Actions → GitHub Container Registry (GHCR) |
The Go binary embeds the compiled frontend at build time (//go:embed). The production container contains a single binary — no Node.js runtime, no npm, no shell.
git clone <your-repo> kryptli && cd kryptli
cp .env.example .env
# Edit .env — set SITE_URL, ADMIN_TOKEN at minimum
docker compose up -dThe app is available at http://localhost:8881 by default.
| Variable | Default | Description |
|---|---|---|
PORT |
3001 |
Internal server port |
DB_PATH |
/app/data/pastes.db |
SQLite database path |
MAX_PASTE_SIZE |
1048576 |
Maximum paste size in bytes (1 MB) |
SITE_URL |
— | Public URL, used to build destroy links in notifications |
ADMIN_TOKEN |
— | Bearer token for /api/admin/* endpoints |
METRICS_TOKEN |
— | Bearer token for /metrics Prometheus endpoint |
LOG_LEVEL |
info |
Log level: debug, info, warn, error |
TELEGRAM_BOT_TOKEN |
— | Telegram Bot API token (optional) |
TELEGRAM_CHAT_ID |
— | Telegram chat ID for abuse notifications (optional) |
Generate strong tokens:
openssl rand -base64 32HTTPS is required for crypto.subtle in browsers. Example Caddyfile:
paste.example.com {
reverse_proxy krypt:3001
}
# docker-compose.yml (excerpt)
services:
krypt:
expose:
- "3001" # remove the ports: mapping when behind a reverse proxy
caddy:
image: caddy:2-alpine
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile
- caddy-data:/data
- caddy-config:/config
volumes:
caddy-data:
caddy-config:# 1. Build the frontend
cd frontend && npm install && npm run build
# 2. Start the Go backend (separate terminal)
cd backend-go
FRONTEND_DIR=../frontend/dist DB_PATH=./data/pastes.db go run .Open http://localhost:3001. FRONTEND_DIR is required here because go run . (without the prod build tag) compiles an empty embedded FS — the Go binary has no frontend of its own and reads it from the filesystem instead.
# 1. Start the Go backend
cd backend-go && DB_PATH=./data/pastes.db go run .
# 2. Start the Vite dev server (separate terminal)
cd frontend && npm install && npm run devOpen http://localhost:5173. Vite proxies all /api requests to the Go backend on :3001. No FRONTEND_DIR is needed — Vite serves the frontend itself.
The production image is built via GitHub Actions and pushed to GHCR on every push to main:
docker pull ghcr.io/ae3ch/krypt.li:mainThe image is built for linux/amd64 and linux/arm64. The Go compiler cross-compiles natively (CGO_ENABLED=0), so no QEMU emulation is used during the build.
Image contents:
- Single Go binary (
~16 MB, includes embedded frontend + migrations) ca-certificatesandtzdata(from distroless base)- No shell, no package manager, no build tools
Approximate image size: ~18 MB
The REST API is consumed by the frontend and the CLI tool. All endpoints return JSON.
| Method | Path | Description |
|---|---|---|
POST |
/api/paste |
Create a paste |
GET |
/api/paste/:id |
Read a paste (increments read count, burns if set) |
GET |
/api/paste/:id/stats |
Read count without incrementing |
DELETE |
/api/paste/:id?token=... |
Delete a paste |
POST |
/api/paste/:id/comments |
Add an encrypted comment |
GET |
/api/paste/:id/comments |
Fetch encrypted comments |
POST |
/api/report |
Report a paste for abuse |
GET |
/api/health |
Health check |
GET |
/metrics |
Prometheus metrics (Bearer token required) |
GET |
/api/admin/reports |
List abuse reports (admin) |
POST |
/api/admin/reports/:id/delete-paste |
Delete a reported paste (admin) |
DELETE |
/api/admin/reports/:id |
Dismiss a report (admin) |
GET |
/api/admin/stats |
Aggregate statistics (admin) |
MIT