Secure one-time secret & image sharing with end-to-end encryption.
Your data never touches the server unencrypted.
How It Works · Deployment · Getting Started · Architecture · Internationalization · License
Only Once Share uses client-side AES-256-GCM encryption via the Web Crypto API. The server never sees plaintext — it only stores encrypted blobs. Secrets can contain text, an image, or both.
CREATE:
Browser → generate master key (AES-256) + secret ID (UUID)
Browser → pack text + optional image into binary envelope
Browser → derive per-secret key: HKDF-SHA-256(masterKey, secretId)
Browser → encrypt: AES-256-GCM(derivedKey, iv=random96bit, aad=secretId)
Browser → POST /api/secrets { ciphertext, ttl, id }
Server → store ciphertext in Redis with TTL
Server → generate 8-char alias, store alias → UUID mapping
Server → return { id: uuid, alias: shortId }
Browser → build link: /s/{alias}?lng=xx#{base64url(masterKey)}
RETRIEVE:
Browser → open link, extract master key from URL fragment (never sent to server)
Browser → GET /api/secrets/{alias}
Server → resolve alias → UUID, fetch ciphertext with GETDEL (atomic delete)
Server → return { ciphertext, id: uuid }
Browser → derive key: HKDF-SHA-256(masterKey, uuid)
Browser → decrypt: AES-256-GCM(derivedKey, iv, aad=uuid) → binary envelope
Browser → unpack envelope → text and/or image
The encryption key lives in the URL fragment (#key), which browsers never send to the server. After one retrieval, the secret is permanently deleted.
| Component | Algorithm | Detail |
|---|---|---|
| Symmetric cipher | AES-256-GCM | 256-bit key, authenticated encryption with associated data |
| IV | Random 96-bit | Generated via crypto.getRandomValues(), unique per secret |
| Key derivation | HKDF-SHA-256 | Derives per-secret key from master key + secret ID |
| Key transport | URL fragment | #base64url(masterKey) — never sent to server by browsers |
The master key in the URL is not used directly for encryption. Instead, a unique key is derived per secret:
derivedKey = HKDF-SHA-256(
ikm: masterKey (256-bit AES key from URL fragment)
salt: "only-once-share-v1"
info: secretId (UUID)
) → 256-bit AES-GCM key
This means:
- Each secret has a cryptographically independent encryption key, even if the master key were somehow reused.
- The derived key is bound to the specific secret ID — it cannot decrypt any other secret.
The secret ID is passed as Additional Authenticated Data to AES-GCM:
ciphertext = AES-256-GCM.encrypt(
key: derivedKey
iv: random 96-bit
aad: secretId (UUID)
data: binary envelope (text + optional image)
)
AAD is not encrypted but is authenticated by the GCM tag. This prevents:
- Ciphertext swapping — moving encrypted data between different secret IDs causes decryption to fail.
- ID tampering — changing the secret ID in the URL invalidates the GCM authentication tag.
The encrypted payload is a single binary blob encoded as base64:
┌─────────┬──────────────┬──────────────────────────────┐
│ Version │ IV │ Ciphertext + GCM Auth Tag │
│ 1 byte │ 12 bytes │ variable length │
└─────────┴──────────────┴──────────────────────────────┘
- Version (
0x01): Enables future algorithm upgrades without breaking existing secrets. - IV: 96-bit initialization vector (NIST recommended size for AES-GCM).
- Ciphertext + Tag: AES-GCM output including the 128-bit authentication tag.
Before encryption, the plaintext is packed into a binary envelope that supports text, images, or both:
Text-only (type 0x00):
┌──────┬────────────────────┐
│ 0x00 │ text (UTF-8) │
└──────┴────────────────────┘
Text + Image (type 0x01):
┌──────┬──────────────┬──────────────┬──────────┬──────────────┬─────────────────┐
│ 0x01 │ text_len (4B)│ text (UTF-8) │ mime_len │ mime (ASCII) │ image (raw) │
│ │ uint32 BE │ │ uint8 │ │ │
└──────┴──────────────┴──────────────┴──────────┴──────────────┴─────────────────┘
- Type
0x00: Text-only secret. Everything after the type byte is UTF-8 text. - Type
0x01: Text + image. Text length is encoded as a 4-byte big-endian uint32 (text can be empty). Image bytes follow the MIME type string. - Legacy: Secrets created before the image feature have no type byte — decoded as raw UTF-8 text. Fully backwards compatible.
- Accepted image types: JPEG, PNG, GIF, WebP (max 10 MB). SVG is excluded to prevent XSS.
- The outer ciphertext format is unchanged — the server cannot distinguish text secrets from image secrets.
https://example.com/s/Kx7mP2nQ?lng=en#iZcjqbPIBnrWwHHkv_KDWeDcUr9hi3A0oMaVbgCVLrg
├────────┤ ├───┤ ├──────────────────────────────────────────────┤
alias lang master key (base64url, 256-bit)
(8 char) URL fragment — NEVER sent to server
- Alias: 8-character base62 short ID mapped to the real UUID in Redis. The UUID is the crypto binding — the alias is purely for shorter URLs.
- Language: Optional
?lng=parameter so recipients see the page in the sender's language. - Master key: The only secret material. Lives exclusively in the URL fragment, which browsers exclude from HTTP requests, server logs, and referrer headers.
| Data | Server has it? | Notes |
|---|---|---|
| Ciphertext | Yes | Encrypted blob, useless without the key |
| Secret ID (UUID) | Yes | Used as Redis key, but not secret |
| Alias | Yes | Maps to UUID, presentation-only |
| TTL | Yes | Expiration time |
| Master key | No | Lives in URL fragment, never transmitted |
| Derived key | No | Computed client-side from master key + UUID |
| Plaintext (text/images) | No | Only exists in the sender's and recipient's browser |
| Content type | No | Cannot distinguish text secrets from image secrets |
| Layer | Detail |
|---|---|
| Encryption | AES-256-GCM with random 96-bit IV per secret |
| Key derivation | HKDF-SHA-256 derives a unique key per secret from master key + secret ID |
| Authenticated data | Secret ID bound as AES-GCM AAD — ciphertext cannot be swapped between secrets |
| Key delivery | URL fragment — never reaches the server |
| Image sharing | JPEG, PNG, GIF, WebP up to 10 MB — encrypted identically to text |
| Zero knowledge | Server stores only ciphertext, cannot distinguish text from images |
| One-time view | Secret is atomically deleted on first retrieval (GETDEL) |
| Auto-expiry | Redis TTL (1–72h) ensures secrets expire even if never viewed |
| Versioned format | Ciphertext includes version byte for future algorithm upgrades |
| Short aliases | 8-char base62 IDs (62^8 = 218 trillion), atomic collision-free generation |
Only Once Share can be used in two ways:
Start sharing secrets immediately at https://ooshare.io — no setup required. The hosted version runs the same open-source code from this repository.
If your organization requires full control over the infrastructure — for compliance, data residency, or security policies — you can deploy Only Once Share on your own servers.
What you need:
- A container runtime (Docker, Kubernetes, ECS, etc.)
- A Redis instance (managed or self-hosted)
- A reverse proxy or load balancer for TLS termination
Steps:
- Clone this repository
- Build the API and UI Docker images (see Getting Started)
- Deploy a Redis instance and set the
REDIS_URLenvironment variable on the API - Set
VITE_API_URLto your API's URL when building the UI (or update the default inui/Dockerfile) - Configure DNS and TLS for your domain
The architecture is stateless (aside from Redis), so it scales horizontally with no changes. All encryption happens client-side — the server never sees plaintext regardless of where it's deployed.
- Docker and Docker Compose
git clone https://github.com/dhdtech/only-once-share.git
cd only-once-share
docker compose up --buildThe app will be available at http://localhost:8080.
Hot reload is enabled — edit files in ui/src/ and changes appear instantly.
For production, the UI Dockerfile builds static assets and serves them via nginx:
docker compose -f docker-compose.yml up --buildNote: Update
docker-compose.ymlto useDockerfileinstead ofDockerfile.devfor the UI service in production.
┌─────────────────────────────────────────────┐
│ Docker Compose │
│ │
│ ┌──────────┐ ┌──────────┐ ┌───────────┐ │
│ │ UI │ │ API │ │ Redis │ │
│ │ React │→ │ Flask │→ │ Storage │ │
│ │ :8080 │ │ :5000 │ │ :6379 │ │
│ └──────────┘ └──────────┘ └───────────┘ │
│ │
└─────────────────────────────────────────────┘
| Tech | Purpose |
|---|---|
| React 19 | Component framework |
| TypeScript | Type safety |
| Vite 6 | Build tool with HMR |
| Web Crypto API | Client-side AES-256-GCM |
| react-i18next | Internationalization |
| Lucide React | Icon system |
| Tech | Purpose |
|---|---|
| Flask 3 | HTTP framework |
| Redis 7 | Encrypted blob storage with TTL |
| Gunicorn | Production WSGI server |
| Method | Path | Description |
|---|---|---|
POST |
/api/secrets |
Store encrypted secret (max 15 MB), returns { id, alias } |
GET |
/api/secrets/:id |
Retrieve and delete encrypted secret (accepts UUID or alias), returns { ciphertext, id } |
GET |
/api/health |
Health check |
The app supports 6 languages out of the box:
| Language | Code |
|---|---|
| English | en |
| 中文 (Chinese) | zh |
| Español (Spanish) | es |
| हिन्दी (Hindi) | hi |
| العربية (Arabic) | ar |
| Português (Portuguese) | pt |
Language selection is persisted in localStorage and embedded in share links (?lng=xx) so recipients see the secret page in the sender's language.
Translation files live in ui/src/i18n/locales/.
only-once-share/
├── api/
│ ├── app.py # Flask API (3 endpoints)
│ ├── requirements.txt # Python dependencies
│ └── Dockerfile # Production container
├── ui/
│ ├── src/
│ │ ├── pages/ # CreateSecret, ViewSecret, Security, About, FAQ, Blog
│ │ ├── components/ # Layout, SecurityModal, LanguageSelector, ImageModal
│ │ ├── lib/ # crypto.ts (AES-GCM + binary envelope), api.ts (fetch client)
│ │ └── i18n/ # i18next config + 6 locale files
│ ├── public/favicon.svg # Shield favicon
│ ├── Dockerfile # Production (nginx)
│ └── Dockerfile.dev # Development (Vite HMR)
├── docker-compose.yml
├── LICENSE
└── README.md
- Fork the repository
- Create a feature branch (
git checkout -b feature/my-feature) - Commit your changes (
git commit -m 'Add my feature') - Push to the branch (
git push origin feature/my-feature) - Open a Pull Request
This project is licensed under the MIT License.