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

OptionTypeDescription
apiKey optionalstringA PRO API key. Raises your quota and attributes links to your account so they appear on your dashboard. Omit it for anonymous use.
baseUrl optionalstringDefaults to https://secretonce.dev. Point it at your own deployment if you self-host.
fetch optionalfunctionA 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

ParameterTypeDescription
plaintext requiredstringThe secret. Must be non-empty; length is capped by your plan.
options.ttl optionalnumberLifetime in seconds. Defaults to 86400 (24 hours). See Expiry.

Returns

const { url, id, key, expiresAt } = await client.create("hunter2", { ttl: 3600 });
FieldTypeDescription
urlstringThe full link, key included as the fragment. This is the thing you send.
idstringThe server-side identifier. Safe to log — it reveals nothing without the key.
keystringThe base64url encryption key. Never log or store this.
expiresAtnumberUnix 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.

SecondsDurationAvailable on
36001 hourAnonymous and up
288008 hoursAnonymous and up
8640024 hours (default)Anonymous and up
2592003 daysFree and up
6048007 daysFree and up
259200030 daysPRO
777600090 daysPRO

Errors

Every failure throws a SecretOnceError carrying a stable code. Branch on the code, not the message.

codeMeaning
invalid_requestBad input — empty secret, unsupported ttl, malformed link.
unauthorizedThe API key is missing, revoked, or not on a PRO plan.
limit_exceededOver a plan limit. err.limit is "expiry" or "size".
rate_limitedDaily creation quota exhausted. Resets at midnight UTC.
goneAlready read, revoked, or expired. One-time means one time.
decrypt_failedThe secret was destroyed but the key was wrong — unrecoverable.
networkThe 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

VariableDescription
SECRETONCE_API_KEYYour PRO API key. The --key flag takes precedence.
SECRETONCE_BASE_URLTarget 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.

REST API →