One API key. One hook. Your users' data is encrypted on their device before it ever leaves.
Your server never sees plaintext — mathematically guaranteed.
Live demo & API keys → encra.dev
// React — E2E encrypted chat in 10 lines
import { useE2EChat } from '@encra/react'
function Chat({ me, recipient }) {
const { messages, isReady, sendMessage } = useE2EChat({
apiKey: process.env.NEXT_PUBLIC_ENCRA_API_KEY,
userId: me,
})
return (
<>
{messages.map((m, i) => <p key={i}><b>{m.from}:</b> {m.text}</p>)}
<button disabled={!isReady} onClick={() => sendMessage(recipient, 'Hello!')}>
Send encrypted message
</button>
</>
)
}Keys are generated on the device. The server stores only public keys and encrypted blobs. Even if the server is hacked, there is nothing readable to steal.
Most apps store user data in plaintext. One breach, one subpoena, one rogue employee — everything is exposed. Encra moves encryption to the client so your server becomes mathematically incapable of reading user data, not just policy-incapable.
- 🔒 HIPAA / GDPR by default — you can't leak what you can't read
- ⚡ 5-minute setup — one hook or one class, no cryptography expertise needed
- 🔑 Zero key management — key generation, exchange, rotation, and persistence handled for you
- 📱 Multi-device ready — each browser/device gets its own key pair; messages are encrypted once per device automatically
- 🛡️ Built on libsodium — the same crypto library used by Signal, WhatsApp, and 1Password
The alternative is months of work:
| Raw Web Crypto | Build your own | Encra | |
|---|---|---|---|
| Setup time | Days | Months | 5 minutes |
| Key server + relay | Build it | Build it | Included |
| Double Ratchet | Build it | Build it | Included |
| State persistence | Build it | Build it | Included |
| Reconnect + backoff | Build it | Build it | Included |
| Cryptographic test vectors | Write them | Write them | Included |
| Ongoing maintenance | You | You | Encra team |
Your server is a blind relay. It stores public keys and forwards encrypted blobs — it has no ability to read the content.
sequenceDiagram
participant A as 🖥️ Alice's device
participant S as ☁️ Encra server
participant B as 🖥️ Bob's device
A->>S: POST publicKey (never the private key)
B->>S: POST publicKey (never the private key)
A->>S: GET Bob's publicKey
S-->>A: Bob's publicKey
B->>S: GET Alice's publicKey
S-->>B: Alice's publicKey
Note over A,B: Both derive the same shared secret locally — it never leaves the device
A->>S: { ciphertext, nonce, header }
Note over S: ⛔ Sees only an encrypted blob — cannot decrypt
S->>B: { ciphertext, nonce, header }
Note over B: DoubleRatchet.decrypt() → "Hello!"
Every message uses a unique one-time key derived from a ratchet chain. Keys are deleted immediately after use — compromising today's key reveals nothing about past or future messages.
| Use case | React | Vanilla / Vue / Svelte / Node |
|---|---|---|
| Real-time chat | useE2EChat() |
EncraClient.sendMessage() |
| Files & media (≤50 MB) | useE2EFile() |
EncraClient.encryptFile() |
| Form submissions | useE2EForm() |
EncraClient.encryptFields() |
| Presence (online / typing / last-seen) | useE2EPresence() |
EncraClient.sendPresence() |
| Database columns | encryptField() from @encra/core |
same |
| Package | Description |
|---|---|
@encra/core |
Pure crypto primitives — X25519, XSalsa20-Poly1305, Double Ratchet, BLAKE2b. Zero framework deps. |
@encra/react |
React hooks — useE2EChat, useE2EFile, useE2EForm. |
@encra/client |
Framework-agnostic EncraClient — Vue, Svelte, Angular, vanilla JS, Node.js. |
@encra/server |
Self-hostable key server + WebSocket relay (BUSL 1.1). |
encra |
CLI — npx encra init, keygen, ping. |
Sign up at encra.dev — free plan, no credit card required.
# Or scaffold everything interactively:
npx encra init# React
npm install @encra/react
# Vue · Svelte · Angular · vanilla JS · Node.js
npm install @encra/client
# Low-level crypto only (no server, no WebSocket)
npm install @encra/coreimport { useE2EChat } from '@encra/react'
function ChatRoom({ userId, recipientId }) {
const { messages, isReady, isConnecting, sendMessage, error } = useE2EChat({
apiKey: process.env.NEXT_PUBLIC_ENCRA_API_KEY!,
userId,
onError: (err) => console.error('Encra error:', err),
})
if (isConnecting) return <p>Connecting…</p>
if (error) return <p>Error: {error.message}</p>
return (
<div>
<ul>
{messages.map((m, i) => (
<li key={i}><strong>{m.from}:</strong> {m.text}</li>
))}
</ul>
<button disabled={!isReady} onClick={() => sendMessage(recipientId, 'Hey!')}>
Send
</button>
</div>
)
}// composable: useEncraChat.ts
import { ref, onMounted, onUnmounted } from 'vue'
import { EncraClient } from '@encra/client'
export function useEncraChat(userId: string) {
const messages = ref<{ from: string; text: string }[]>([])
const isReady = ref(false)
const client = new EncraClient({ apiKey: import.meta.env.VITE_ENCRA_KEY, userId })
onMounted(async () => {
client.on('message', () => { messages.value = [...client.messages] })
client.on('ready', () => { isReady.value = true })
await client.connect()
})
onUnmounted(() => client.disconnect())
return { messages, isReady, sendMessage: client.sendMessage.bind(client) }
}import { EncraClient } from '@encra/client'
const client = new EncraClient({
apiKey: process.env.ENCRA_API_KEY,
userId: 'alice',
serverUrl: 'https://api.encra.dev', // optional — this is the default
})
client.on('ready', () => console.log('🔒 Connected'))
client.on('message', (msg) => console.log(`${msg.from}: ${msg.text}`))
client.on('error', (err) => console.error(err))
await client.connect()
await client.sendMessage('bob', 'Hello, Bob!')
client.disconnect()import { useE2EFile } from '@encra/react'
function FileShare({ userId, recipientId }) {
const { encryptFile, isReady } = useE2EFile({
apiKey: process.env.NEXT_PUBLIC_ENCRA_API_KEY!,
userId,
})
async function handleUpload(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0]
if (!file) return
const encrypted = await encryptFile(file, recipientId)
// Upload ciphertext however you like — S3, R2, your DB, etc.
await fetch('/api/files', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(encrypted),
})
}
return <input type="file" disabled={!isReady} onChange={handleUpload} />
}import { useE2EForm } from '@encra/react'
function MedicalForm({ patientId, doctorId }) {
const { encryptFields, isReady } = useE2EForm({
apiKey: process.env.NEXT_PUBLIC_ENCRA_API_KEY!,
userId: patientId,
})
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault()
const data = Object.fromEntries(new FormData(e.currentTarget)) as Record<string, string>
// Only the doctor can decrypt — your server stores ciphertext only
const encrypted = await encryptFields(data, doctorId)
await fetch('/api/intake', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(encrypted),
})
}
return (
<form onSubmit={handleSubmit}>
<input name="ssn" placeholder="SSN" />
<input name="dateOfBirth" placeholder="Date of birth" />
<input name="chiefComplaint" placeholder="Chief complaint"/>
<button disabled={!isReady} type="submit">Submit (encrypted)</button>
</form>
)
}import { generateFieldKey, encryptField, decryptField } from '@encra/core'
// Generate once — store in AWS Secrets Manager, Vault, etc. Never in the DB.
const key = await generateFieldKey()
// Encrypt before INSERT
const encryptedSSN = await encryptField('123-45-6789', key)
// → { ciphertext: "base64...", nonce: "base64..." }
// Decrypt after SELECT
const ssn = await decryptField(encryptedSSN, key)
// → "123-45-6789"Most apps store user data in plaintext on their servers. A single breach — or a subpoena — exposes everything. Encra moves encryption to the client so your server becomes mathematically incapable of reading user data, not just policy-incapable.
You could. But you'd need to implement X25519 key exchange, Double Ratchet from scratch (forward secrecy, out-of-order messages, state persistence), a key server, a WebSocket relay, reconnection logic, offline delivery, and cryptographic test vectors. Encra is the production-grade version of that work — auditable and open source.
Encra uses the same cryptographic constructions as Signal:
| Purpose | Algorithm |
|---|---|
| Identity keys | Ed25519 (sign/verify), converted to X25519 for DH |
| Session setup | X3DH (Extended Triple Diffie-Hellman) with signed + one-time prekeys |
| Messaging | Double Ratchet with header encryption |
| Key agreement | X25519 (ECDH) |
| Encryption | XSalsa20-Poly1305 (authenticated) |
| KDF / ratchet | Keyed BLAKE2b-256 |
| Randomness | OS CSPRNG via libsodium randombytes_buf |
Chat sessions are established with X3DH: each device publishes an identity key, a signed prekey, and a pool of one-time prekeys, so a sender can open an authenticated session with an offline recipient. The signed-prekey signature is verified before any session is created, which defeats a key-substituting server. Messages then flow through a Double Ratchet whose headers are encrypted, so the relay never sees the ratchet public key or message counters.
React 18+, Vue 3, Svelte, Angular, vanilla JS (browser), Node.js 18+, and React Native (@encra/core only).
Under 5 minutes: install the package, set your API key, drop in one hook or class. Or run npx encra init for an interactive wizard.
| Threat | How |
|---|---|
| Server breach | Server stores only public keys + ciphertext. No plaintext, no private keys. |
| Network interception | XSalsa20-Poly1305 authenticated encryption — tampering is detected and rejected. |
| Key compromise exposing past messages | Double Ratchet with per-message key deletion (forward secrecy). |
| Key compromise exposing future messages | DH ratchet step on every direction change (break-in recovery). |
| Key-substituting (MITM) server | X3DH verifies the signed-prekey signature against the peer's identity key before opening a session. |
| Ratchet metadata leakage to the relay | Message headers (ratchet public key + counters) are encrypted under a header key. |
| Weak randomness | All nonces and key pairs via libsodium randombytes_buf (OS CSPRNG). |
- Compromised endpoint — if the device is fully compromised (malware, physical access), Encra cannot help.
- Routing metadata — message contents and ratchet headers are encrypted, but the relay still routes by sender/recipient id, so it knows who communicated and when, not what. (Sealed-sender support is on the roadmap.)
- Identity-key trust on first use — X3DH stops a server from swapping a prekey, but you should still confirm a peer's identity key out of band with
generateFingerprint().
Recipient publishes: Identity Key (Ed25519)
Signed Prekey (X25519, signed by identity key)
One-Time Prekeys (X25519, consumed once each)
Sender fetches the bundle, verifies the signed-prekey signature, then runs
four Diffie-Hellman operations:
DH1 = DH(IK_sender, SPK_recipient)
DH2 = DH(EK_sender, IK_recipient)
DH3 = DH(EK_sender, SPK_recipient)
DH4 = DH(EK_sender, OPK_recipient) ← omitted if no one-time prekey is left
session keys = KDF(DH1 ‖ DH2 ‖ DH3 ‖ DH4)
This yields an authenticated shared secret even when the recipient is offline.
Root Key
│
├─► Chain Key 1 ──► Message Key 1 (used once, then deleted from memory)
│ │
│ └─► Chain Key 2 ──► Message Key 2 (used once, then deleted from memory)
│
└─► (DH ratchet step on direction flip — new root key, new chains, new header keys)
Every message header (ratchet public key + counters) is encrypted under a
per-direction header key, so the relay sees only opaque ciphertext.
If an attacker compromises today's key: past messages are safe (keys already deleted), future messages are safe after the next DH ratchet step.
// Encrypted real-time chat
const { messages, isReady, isConnecting, sendMessage, error } = useE2EChat({
apiKey: string,
userId: string,
serverUrl?: string, // default: https://api.encra.dev
onError?: (err: Error) => void,
onWireMessage?: (event: WireEvent) => void,
})
// Encrypted file transfer (up to 50 MB)
const { encryptFile, decryptFile, isReady, error } = useE2EFile({
apiKey: string,
userId: string,
serverUrl?: string,
onError?: (err: Error) => void,
})
// Encrypted form fields
const { encryptFields, decryptFields, isReady, error } = useE2EForm({
apiKey: string,
userId: string,
serverUrl?: string,
onError?: (err: Error) => void,
})
// Encrypted presence — online/offline, typing, last-seen, ghost mode
const { presence, isReady, ghostMode, setGhostMode, sendTyping, setStatus, error } = useE2EPresence({
apiKey: string,
userId: string,
contacts: string[], // user IDs to track and broadcast presence to
serverUrl?: string,
onError?: (err: Error) => void,
})
// presence is a map keyed by userId:
// { [userId]: { status: 'online'|'offline'|'away'|'busy', lastSeenAt: number|null, isTyping: boolean } }
// Presence updates are ephemeral (never queued) and encrypted under a key derived
// from an authenticated X3DH session — never under static device keys.
// Shared types (also exported from @encra/client)
interface DeviceKey { deviceId: string; publicKey: Uint8Array }
// encryptFile / encryptFields return a multi-device envelope —
// one ciphertext per registered device of the recipient:
interface EncryptedFile {
name: string; mimeType: string; size: number
devices: Array<{ deviceId: string; ciphertext: Uint8Array; nonce: Uint8Array }>
}
interface EncryptedFields {
devices: Array<{
deviceId: string
fields: Record<string, { ciphertext: string; nonce: string }>
}>
}const client = new EncraClient({ apiKey, userId, serverUrl? })
// Lifecycle
await client.connect()
client.disconnect()
// Messaging
await client.sendMessage(to: string, text: string)
// File encryption (≤ 50 MB)
await client.encryptFile(file: File | Blob, to: string) // → EncryptedFile
await client.decryptFile(encrypted: EncryptedFile, from: string) // → File
// Form field encryption (independent per-field nonces)
await client.encryptFields(fields: Record<string, string>, to: string) // → EncryptedFields
await client.decryptFields(encrypted: EncryptedFields, from: string) // → Record<string, string>
// Presence (ephemeral, session-derived key — never queued)
await client.sendPresence(to: string, payload: PresencePayload) // { status, lastSeenAt, isTyping }
await client.setGhostMode(enabled: boolean) // broadcasts offline, then suppresses sends
client.ghostMode // boolean
// State
client.isReady // boolean
client.isConnecting // boolean
client.messages // Message[]
client.error // Error | null
// Events
client.on('ready' | 'connecting' | 'disconnected' | 'message' | 'error' | 'wire', listener)
client.on('presence', (event) => console.log(event.from, event.payload.status))
client.off(event, listener)import {
generateKeyPair, exportKey, importKey, sodiumReady,
deriveSharedSecret,
encrypt, decrypt,
generateFieldKey, encryptField, decryptField,
// Presence (symmetric, domain-separated from message keys)
derivePresenceKey, encryptPresence, decryptPresence,
generateFingerprint,
// Identity keys (Ed25519) + X3DH
generateIdentityKeyPair, sign, verify,
generateSignedPreKey, generateOneTimePreKeys, buildPreKeyBundle,
x3dhInitiate, x3dhRespond,
// Double Ratchet with header encryption
DoubleRatchet,
InvalidKeyError, DecryptionFailedError, KeyNotFoundError,
} from '@encra/core'npx encra init # Interactive setup — writes .env.example + starter component
npx encra keygen # Generate a test X25519 key pair + fingerprint
npx encra ping # Verify server reachability and API key validity| Method | Path | Description |
|---|---|---|
GET |
/health |
Liveness check |
POST |
/v1/keys |
Register / update a device's public key |
GET |
/v1/keys/:userId |
Fetch all device public keys for a user → { userId, devices: [{ deviceId, publicKey }] } |
POST |
/v1/prekeys |
Publish / replenish a device's X3DH bundle (identity key, signed prekey, one-time prekeys) |
GET |
/v1/prekeys/:userId/:deviceId |
Fetch a prekey bundle (atomically consumes one one-time prekey) |
GET |
/v1/prekeys/:userId/:deviceId/count |
Remaining one-time prekey count (for replenishment) |
WS |
/v1/relay |
WebSocket relay — authenticate with a { type: "auth", token } message, then register; routes encrypted messages |
All HTTP endpoints require Authorization: Bearer <api_key>. The WebSocket
relay authenticates via its first message (the token is not placed in the
URL, keeping it out of logs).
| Managed (encra.dev) | Self-hosted | |
|---|---|---|
| Setup | Get an API key, done | Clone, configure Postgres, deploy |
| Cost | Free tier + paid plans | Your own infra costs |
| Maintenance | Zero | You own it |
| Data location | Encra servers | Wherever you deploy |
| License | — | BUSL 1.1 (see below) |
packages/serveris BUSL 1.1 — self-hosting is permitted for non-commercial use.
git clone https://github.com/adityayaduvanshi/encra
cd encra && npm install
# Configure
cp packages/server/.env.example packages/server/.env
# Set DATABASE_URL and JWT_SECRET
# Migrate (run in order)
psql $DATABASE_URL -f packages/server/migrations/001_init.sql
psql $DATABASE_URL -f packages/server/migrations/002_message_queue_header.sql
psql $DATABASE_URL -f packages/server/migrations/003_device_keys.sql
psql $DATABASE_URL -f packages/server/migrations/004_prekeys.sql
psql $DATABASE_URL -f packages/server/migrations/005_header_encryption.sql
# Build & start
npm run build --workspace=packages/server
npm start --workspace=packages/servernpm install # Install all workspace deps
npm test # Run all tests (100+ core · 35 react · 26 client · 46 server)
npm run build # Build all packages
node e2e-test.mjs # Alice → Bob end-to-end integration testTests use Vitest with cryptographic test vectors — the real libsodium primitives, never mocked.
| Package | License |
|---|---|
packages/core |
Apache 2.0 |
packages/client |
Apache 2.0 |
packages/react |
Apache 2.0 |
packages/cli |
Apache 2.0 |
packages/server |
BUSL 1.1 → Apache 2.0 on 2030-01-01 |
For commercial self-hosting licenses: legal@encra.dev