Self-hosted service
Luzifer/ots avatar
Luzifer/ots

Luzifer/ots: a one-time secret sharing service that encrypts in the browser

One-Time-Secret sharing platform with a symmetric 256bit AES encryption in the browser

803 stars98 forksGoApache-2.0

At a glance

What is it?
ots is a Go server for sharing secrets that the server cannot read. The secret is encrypted with AES 256 in the browser, the password never leaves the client, and the secret is deleted on first read, with a CLI for scripts and a Vue frontend that does the decryption.
Who is it for?
ots suits a team that needs to hand a password or a token to someone else once, over a channel they cannot encrypt, and that wants the server operator to be technically unable to read what it carries. It is the wrong tool for a secret that has to be shared twice, for a file that has to be shared with several people, and for a workflow where a link is expected to be re-openable.
Can I use it commercially?
Yes. Apache-2.0 is a permissive licence: you can use, modify and sell software built on it, as long as you keep its copyright and licence notices.
Is it still maintained?
Yes. The repository last received commits 1 day ago.
What is it written in?
Mainly Go, according to GitHub's language statistics.

Answers come from the project's GitHub data, last synced on October 22, 2026, and from our analysis. They are not legal advice.

Editorial analysis

The three properties the design rests on

ots is a one-time-secret sharing platform, and the README states the mechanism in one dense paragraph. The secret is encrypted with a symmetric 256bit AES encryption in the browser before being sent to the server. Afterwards a URL containing the ID of the secret and the password is generated. The password is never sent to the server, so the server will never be able to decrypt the secrets it delivers with a reasonable effort. Also the secret is immediately deleted on the first read.

Those three properties are the whole product, and they are worth separating because each one answers a different objection.

The first answers the objection that a paste service operator could read what you sent. Since encryption happens in the browser, the plaintext never exists on the server, and the key material never exists there either.

The second answers the objection that the operator could log the password. The password is the URL fragment, the part after the #, and browsers do not send that to the server. The README explains this explicitly in the bash section: due to how browsers are handling hashes in URLs, the only URL the server gets to know is the base address, which loads the frontend. Afterwards the JavaScript executed in the browser fetches the encrypted secret at the given ID and decrypts it with the given password.

The third answers the objection about persistence. A secret that is deleted on first read is a secret that cannot be read twice, which removes the long tail of exposure that a stored paste has.

Running the server and choosing a storage backend

The setup is a release download or a container image, and the README keeps it short. Download the release or pull the Docker image from ghcr.io/luzifer/ots, start it, and you can access the server on http://localhost:3000/. The full option set is in the ots binary itself, with ./ots --help.

The choice that matters is the storage backend.

The mem backend is in-memory storage, wiped on restart of the daemon. That is a sensible default for a personal instance, and it has a useful property for a shared one, since a restart destroys every outstanding secret.

The redis backend stores the secrets in a hash under one key, configured with REDIS_URL, a Redis connection string of the form redis://USR:PWD@HOST:PORT/DB, and REDIS_KEY, the key prefix to store the keys under, defaulting to io.luzifer.ots.

There is a common option as well: SECRET_EXPIRY, the expiry of the keys in seconds, with a default of 0 meaning no expiry.

That default deserves attention. With no expiry, a secret that is created and never read stays in the store forever, which is a long-lived liability for anything sensitive. The compose file in the repository sets it to 604800 seconds, which is one week, and that is a much more sensible default for a deployment than the built-in one.

The docker-compose file in the repository is a good template. It runs the image ghcr.io/luzifer/ots:v1.23.0 with STORAGE_TYPE set to redis, REDIS_URL pointing at a valkey service, SECRET_EXPIRY set to a week, port 3000 published, and a valkey container with a data volume.

The CLI for scripts, and how it differs from the browser path

The README is explicit about a subtlety here. As ots is designed to never let the server know the secret you are sharing you should not just send the plain secret to it, though it is possible.

So the CLI, which comes from the Releases section or can be built from the ./cmd/ots-cli directory with a Go toolchain, offers a create and a fetch pair.

bash
# echo "my password" | ots-cli create
# ots-cli fetch 'https://ots.fyi/#37a75a7f-0c2d-4ae6-bcca-4208b6d596ab%7CHGShVWm5umv4lmswfM73'

The output of create is a URL with the identifier and the password, and fetch prints the secret back. The two commands are designed to be scriptable. The create command reads from standard input or a specified file and yields the URL to standard output, and the fetch command prints the secret to standard output and stores any attached files to a given directory. Both send logs to standard error, which you can disable with --log-level=fatal or simply ignore in a script.

The instance can be set per call with --instance, and credentials for a custom instance that needs them are passed the way you would with curl, with -u for basic auth and -H for a token header, and you can repeat the header flag.

For convenience, exporting the instance in the OTS_INSTANCE environment variable avoids passing --instance every time.

The URL format is worth reading carefully, because it explains the whole design. The example uses a percent-encoded pipe between the secret ID and the password, since a literal | in a URL fragment is not always safe to type, and the browser reads the fragment, splits it, and uses the second half as the decryption key.

The bash path, where you encrypt before sending

The README calls this strongly recommended, and the reasoning is about trust rather than about the public instance specifically. Especially if you are using ots.fyi, the public hosted instance, you should not trust the operator with your secret but use an encrypted secret.

The command encrypts locally and then posts the ciphertext to the API.

bash
# echo "my password" | openssl aes-256-cbc -base64 -pass pass:mypass -pbkdf2 -iter 300000 -md sha512
# curl -X POST -H 'content-type: application/json' -i -s -d '{"secret": "U2FsdGVkX18wJtHr6YpTe8QrvMUUdaLZ+JMBNi1OvOQ="}' https://ots.fyi/api/create

The parameters are worth noticing. The cipher is aes-256-cbc, the key derivation is pbkdf2 with 300000 iterations and sha512, and the output is base64. The iteration count is the part that matters for security, since a low count would make the passphrase cheap to brute force offline.

The API response is minimal. It returns HTTP 201 with a secret_id and a success flag, and nothing about the content.

Then the URL is assembled with the ID and the password joined by a pipe, such as https://ots.fyi/#5e0065ee-5734-4548-9fd3-bb0bcd4c899d|mypass, and the README repeats the explanation of the hash handling. The server sees only the base address, the JavaScript in the browser fetches the encrypted secret by its ID and decrypts it with the password from the fragment.

So there are two ways to use the API. Send plaintext and trust the operator, or encrypt locally and trust only the transport. The second is the one the author wants you to use, and the difference is exactly one openssl invocation.

The Go server, the Vue frontend and the local development loop

The implementation is a Go server with a Vue frontend, and the repository layout shows the split. At the root of the Go side there is main.go, api.go, storage.go, helpers.go, tplFuncs.go and a pkg/ directory, and on the frontend side there is a frontend/ directory, an src/ directory, a package.json and a pnpm-workspace.yaml.

The frontend dependencies in the package.json are worth a look, because they explain what the browser does. There is vue at 3.5.44 with vue-router and vue-i18n for routing and localisation, bootstrap 5.3.8 for the styling, base64-js, the qrcode package for generating a QR code from a secret URL, and the FontAwesome free icon set.

The Go dependencies are similarly revealing. gorilla/mux is the router, go-redis/v9 is the Redis client, gofrs/uuid generates the secret identifiers, logrus does the logging, rconfig handles configuration, prometheus/client_golang exports metrics, and testify is the test framework. The module also has a replace directive for the two internal packages pkg/customization and pkg/tplfunc, which are local modules with their own go.mod files.

The customisation mechanism is what the wiki page points at. The repository has a customization directory, and the compose file shows how it is wired in, with a CUSTOMIZE variable pointing at /etc/ots/customize.yaml, and the tplFuncs.go file providing the template functions used to render the customised frontend. That is how the instance can be branded without forking the project.

For local development the repository ships a Tiltfile for tilt v0.33 and later, and the requirements are Go v1.23 or newer, Node v22 or newer, and Tilt v0.33 or newer. Running tilt up gives a Tilt dashboard on http://localhost:10350/ and the frontend application on http://localhost:15641/.

The container build is a two stage build that ends in a scratch image, so the running container contains only the ots binary and nothing else, running as user 1000:1000 with port 3000 exposed.

Where ots is the wrong tool

The first limitation is the one-time nature of the secret. It is deleted on first read, which is the property that makes it attractive and the property that makes it unsuitable for anything else. A link that is opened twice returns nothing, so a secret cannot be re-shared, cannot be opened on two devices by design, and cannot be used as a link in a ticket that a colleague will read next Tuesday.

The second is the size of the payload. The secret travels in a URL fragment, and URLs have length limits in browsers, mail clients and chat systems. A password is fine, a short token is fine, and a file is not. The CLI can attach files, but that is the CLI's own download-and-store behaviour, not a browser payload.

The third is the key derivation in the bash path. The example uses pbkdf2 with 300000 iterations, which is reasonable, but the key is a passphrase chosen by a human, and a human passphrase is the weakest link in a scheme whose whole point is that the server cannot read the secret. The browser path handles this differently, since the password there is used as the key directly, which means the security of a browser-created secret is the strength of the password itself.

The fourth is the operational detail of the default expiry. SECRET_EXPIRY defaults to 0, meaning no expiry, so a deployment that does not set it accumulates unread secrets indefinitely in Redis.

The fifth is the lack of authentication in the base product. The README mentions that an instance can require credentials for the /api/create endpoint, and shows how to pass them to the CLI, but the base setup has no authentication, so a public instance is anyone with an API key of their own. The CLI supports basic auth and token headers for instances that need them, and the compose example does not enable them.

ots compared with a password manager, with a file drop service and with age or SOPS

The comparison with a password manager is the first one, and the difference in approach is about who holds the secret and for how long. A password manager holds secrets long term, encrypted at rest under a master key, and is designed for a set of credentials you reuse. ots holds one secret for one read, and the design goal is that the operator cannot read it. A shared password manager vault is a much better fit for credentials you need repeatedly, and it is a worse fit for handing a secret to someone exactly once over an insecure channel, because the vault itself becomes the thing you have to trust.

The comparison with a file drop service is the same shape. Services such as those that accept an upload and give you a link are widely used and their retention policies vary, which is exactly the objection ots addresses by deleting on first read. If your requirement is one-time access to a file, the difference is that ots's payload is small.

The third comparison is with age and SOPS, and it is the most instructive. age encrypts files with a public key so that only the holder of a private key can decrypt, and SOPS encrypts values inside files that stay readable to the tools that need to read them, such as YAML in a Git repository. Both are better than ots for anything long lived, because they use asymmetric keys and can be verified, rotated and revoked.

So the honest framing is that ots solves a narrow problem that those tools solve badly. A one-time secret over an insecure channel, where the recipient is not known in advance and no long-term key exchange is possible.

The fourth comparison is with a self-hosted service that does the same thing, and the difference is the browser-side encryption. A server-side paste service with a delete-on-read policy is a much smaller project, and it is much weaker, because the operator holds the plaintext for the moment of the read. ots moves that moment into the browser, which is the whole design.

Licence, releases and what to set before you hand out a link

The licence field on the repository says Apache-2.0, and the Dockerfile's own labels carry org.opencontainers.image.licenses set to Apache-2.0, along with the author, version, url, documentation and source labels. That labelling is a good sign, since it means a container pulled from the registry carries its own provenance metadata.

The project is not archived and the last push was on 2026-10-19, so it is actively maintained. The release history shows v1.23.0 from 2026-10-06, v1.22.0 from 2026-09-27 and v1.21.9 from 2026-07-25, which is a steady cadence for a small service, and the version is used in both the compose file and the container labels, so an image tag and a release tag line up.

The repository also has a SECURITY.md, a CONTRIBUTING.md, a History.md for the changelog, and renovate.json for dependency updates, and the CI configuration lives in a ci/ directory alongside a .forgejo/ directory, so the project builds on more than one forge.

Four things to set before you hand out a first link.

The expiry, since SECRET_EXPIRY defaults to 0 and the compose example shows a week as a sane value. A secret that is never read should not survive indefinitely.

The storage backend, and if it is mem, accept that a restart destroys every outstanding secret, which is a feature for a personal instance and an operational surprise for a shared one.

The authentication on the create endpoint, if the instance is reachable from outside your network. The CLI supports basic auth and arbitrary token headers for exactly this.

And the branding, through the customization file the wiki describes, because a public instance that looks like somebody else's service is a bad look and a worse phishing target.

Editorial conclusion

ots suits a team that needs to hand a password or a token to someone else once, over a channel they cannot encrypt, and that wants the server operator to be technically unable to read what it carries. It is the wrong tool for a secret that has to be shared twice, for a file that has to be shared with several people, and for a workflow where a link is expected to be re-openable. Before deploying, set SECRET_EXPIRY, because the default of zero means the keys never expire, and put the API behind authentication if the instance is reachable from outside your network.

Frequently asked questions

How does Luzifer/ots keep the server from reading a secret?

The secret is encrypted with AES 256 in the browser before it is sent, and the password is kept in the URL fragment after the #, which browsers do not send to the server. The server receives only the encrypted blob and an identifier, so it has no key material with which to decrypt.

What happens to a secret after it is read?

It is immediately deleted on the first read. The README lists this as one of the three core features, alongside browser-side encryption and the server never receiving the plain text secret.

How do I run the ots server?

Download a release or pull the image ghcr.io/luzifer/ots, then start it and access the server on http://localhost:3000/. Run ./ots --help for the full option list, and pick a storage backend, mem for in-memory storage wiped on restart, or redis for storage in a hash under one key configured with REDIS_URL and REDIS_KEY.

How do I share a secret from a shell script?

Use ots-cli, which reads the secret from standard input with create and writes the URL to standard output, and fetches a secret back to standard output with fetch. Logs go to standard error and can be silenced with --log-level=fatal. Set the instance with --instance or the OTS_INSTANCE environment variable.

How do I encrypt a secret before sending it to the ots API?

The README's bash approach pipes the secret through openssl aes-256-cbc with base64 output and pbkdf2 key derivation at 300000 iterations, then posts the ciphertext to the /api/create endpoint. The password is then supplied in the URL fragment after the #.

What licence is Luzifer/ots released under?

Apache-2.0, with the terms in the LICENSE file, and the container image labels the same licence in its org.opencontainers.image.licenses field.

Official sources

  1. License: Apache-2.0
  2. Luzifer/ots on GitHub
  3. Project website
  4. README
  5. Releases
Add this badge to your README

If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.

Add this badge to your README

markdown
[![Hysen Labs](https://hysenlabs.com/badge/luzifer-ots.svg)](https://hysenlabs.com/projects/luzifer-ots)

Was this useful?