Tools / npm
secretonce
Create end-to-end encrypted one-time secret links from JavaScript or your shell. The key is generated in your process and lives only in the URL fragment — it never reaches the server, which stores ciphertext and nothing else.
Zero dependencies · No build step · MIT · Node 18+, Deno, Bun, edge runtimes, browsers
Install
npm install secretonce
No dependencies, no build step, no native modules. The package ships as ES modules and uses only the Web Crypto API, so the same code runs on a server, at the edge, or in a browser tab.
Quick start
import { createSecret, readSecret } from "secretonce";
const { url } = await createSecret("hunter2", { ttl: 86400 });
// => https://secretonce.dev/s/abc123#8mHtd_UOyJYG8h8Pak-r2LI05EjejihQCvgqxCtt868
const plaintext = await readSecret(url); // the secret is destroyed by this call
Both helpers are thin wrappers around a default client. Reach for the class when you need an API key or a custom host.
The client
import { SecretOnce } from "secretonce";
const client = new SecretOnce({
apiKey: process.env.SECRETONCE_API_KEY, // optional; PRO accounts only
baseUrl: "https://secretonce.dev", // optional; override for self-hosting
});
Options
| Option | Type | Description |
|---|---|---|
apiKey optional | string | A PRO API key. Raises your quota and attributes links to your account so they appear on your dashboard. Omit it for anonymous use. |
baseUrl optional | string | Defaults to https://secretonce.dev. Point it at your own deployment if you self-host. |
fetch optional | function | A fetch implementation. Defaults to the global one; pass your own to add retries, proxying, or test doubles. |
client.create(plaintext, options)
Generates a fresh 256-bit key, encrypts plaintext locally, uploads only the ciphertext, and returns the shareable link. The key is never transmitted.
Parameters
| Parameter | Type | Description |
|---|---|---|
plaintext required | string | The secret. Must be non-empty; length is capped by your plan. |
options.ttl optional | number | Lifetime in seconds. Defaults to 86400 (24 hours). See Expiry. |
Returns
const { url, id, key, expiresAt } = await client.create("hunter2", { ttl: 3600 });
| Field | Type | Description |
|---|---|---|
url | string | The full link, key included as the fragment. This is the thing you send. |
id | string | The server-side identifier. Safe to log — it reveals nothing without the key. |
key | string | The base64url encryption key. Never log or store this. |
expiresAt | number | Unix seconds at which the secret expires if nobody reads it first. |
client.read(urlOrId, key?)
Fetches, destroys, and decrypts in one step, resolving to the plaintext string. Reading is atomic: exactly one caller ever receives a given secret, and a second call throws gone.
await client.read("https://secretonce.dev/s/abc123#8mHtd_…"); // a full link
await client.read("abc123", "8mHtd_…"); // id and key separately
A full link carries the host that issued it, so it is read from there regardless of baseUrl — a link from a self-hosted instance resolves correctly even through a client configured for somewhere else. The two-argument form reads from baseUrl instead.
Because the call destroys the secret before decrypting, handle the result carefully: if your process crashes between the response and using the value, the secret is gone.
Expiry
ttl is in seconds and must be one of the accepted values. Your plan caps how long a secret may live; anything above it is rejected with limit_exceeded.
| Seconds | Duration | Available on |
|---|---|---|
3600 | 1 hour | Anonymous and up |
28800 | 8 hours | Anonymous and up |
86400 | 24 hours (default) | Anonymous and up |
259200 | 3 days | Free and up |
604800 | 7 days | Free and up |
2592000 | 30 days | PRO |
7776000 | 90 days | PRO |
Errors
Every failure throws a SecretOnceError carrying a stable code. Branch on the code, not the message.
code | Meaning |
|---|---|
invalid_request | Bad input — empty secret, unsupported ttl, malformed link. |
unauthorized | The API key is missing, revoked, or not on a PRO plan. |
limit_exceeded | Over a plan limit. err.limit is "expiry" or "size". |
rate_limited | Daily creation quota exhausted. Resets at midnight UTC. |
gone | Already read, revoked, or expired. One-time means one time. |
decrypt_failed | The secret was destroyed but the key was wrong — unrecoverable. |
network | The server was unreachable or returned an unexpected status. |
import { SecretOnceError } from "secretonce";
try {
await createSecret(pw);
} catch (err) {
if (err instanceof SecretOnceError && err.code === "rate_limited") {
console.error("Out of links for today.");
}
}
Command line
The package installs a secretonce command — a convenience wrapper over the same client, for shells and CI jobs.
echo "hunter2" | npx secretonce # stdin keeps it out of shell history
npx secretonce --ttl 7d "hunter2"
npx secretonce --read "https://secretonce.dev/s/abc123#key"
Environment
| Variable | Description |
|---|---|
SECRETONCE_API_KEY | Your PRO API key. The --key flag takes precedence. |
SECRETONCE_BASE_URL | Target host. The --base-url flag takes precedence. |
The link goes to stdout and everything else to stderr, so it composes cleanly:
LINK=$(echo "$PASSWORD" | secretonce --ttl 1h)
Security
Never ship an API key to a browser. Anything in client-side JavaScript is public. Use the key from a server, a CI job, or the CLI; in a browser, make anonymous calls or proxy through your own backend.
The key never reaches the server, so nobody — including us — can read a secret from the stored ciphertext. The corollary is that a lost key means a lost secret: there is no recovery, no reset, no support ticket that gets it back.
The wire format is frozen — AES-256-GCM, a 12-byte IV prefix, base64url — and pinned by fixed test vectors, so every link ever issued stays readable.
Not writing JavaScript?
The same two endpoints are documented for direct HTTP use from any language.