Self-hosted service
AndrewPaglusch/FlashPaper avatar
AndrewPaglusch/FlashPaper

FlashPaper: a self-destructing secret link the server cannot read

One-time encrypted password/secret sharing

521 stars73 forksPHPMIT

At a glance

What is it?
FlashPaper is a PHP web application for sharing a password or secret exactly once, with two layers of AES-256-CBC encryption, a bcrypt-checked one-time URL and a built-in pruning schedule. The server stores only ciphertext, and the key needed to decrypt it travels in the link, so the operator cannot read what it stores.
Who is it for?
FlashPaper fits a team that needs to hand one password or one secret to one person exactly once, over a channel it controls, without leaving a readable copy on a server it does not fully trust. It is the wrong tool for anything ongoing, since there is no revocation, no rotation and no shared vault, and it is the wrong tool when the recipient might open the link from a mail preview rather than the message itself.
Can I use it commercially?
Yes. MIT 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 PHP, according to GitHub's language statistics.

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

Editorial analysis

A one-time secret link where the server never sees the plaintext

FlashPaper is a one-time encrypted zero-knowledge password and secret sharing application, and the README states its goal in one line: no database or complicated set-up required. The interesting property is what the server holds. In a typical pastebin-style secret sharing tool, the server stores the plaintext and the retrieval link is a key to it, which means whoever runs the server can read every secret that has not been viewed. FlashPaper is built so that the server stores only ciphertext, and the key material needed to decrypt lives in the URL that goes to the recipient. The topics list names the primitives, with aes, aes-256, bcrypt, ciphertext, destruct, disposable, one-time-secret and zero-knowledge among them, and the licence is MIT with the last push on 2026-08-05.

Two layers of AES and a bcrypt check on the URL

The How It Works section is a numbered walk through, and it is worth following in order. On submission, a random-named sqlite database is created if it does not already exist, and a randomized 256-bit AES static key is created if one does not exist already. Then a random 256-bit AES key and a random 128-bit IV are generated, along with a random 64-bit ID. The ID and the AES key are joined and hashed with bcrypt. The submitted text is encrypted with AES-256-CBC using the per-secret key and the random IV, and that ciphertext is then encrypted again with AES-256-CBC using the static key and the same random IV. The ID, IV, bcrypt hash, ciphertext and prune epoch go into the database, and the joined `k` value, which is the ID plus the AES key, is returned to the user in a one-time URL.

Retrieval deletes the entry before anything else

The retrieval path is the mirror image, and the ordering is the security property. The `k` value is removed from the URL, split into its two parts, the ID and the AES key, and the IV, bcrypt hash and ciphertext are looked up in the database using the ID. The bcrypt hash of `k` is compared against the stored bcrypt hash, which the README says prevents tampering of the URL. Only then is the ciphertext decrypted with the static AES key and the IV, and then decrypted again with the AES key that came out of `k`. The entry is deleted from the database, and only after that is the decrypted text sent to the user. The two layers mean the static key alone does not decrypt anything, and the per-secret key alone is useless without the static key.

Deploying it with Docker takes four steps

The recommended installation path is Docker, and the published image is at `ghcr.io/andrewpaglusch/flashpaper`. The README gives four steps. Download the docker-compose.yml from the repository, edit it with your customizations, run `docker-compose up -d` to start FlashPaper, and set up a reverse proxy in front of FlashPaper that terminates SSL/TLS. That last step is not optional in practice, since a secret sharing service that hands out links over plain HTTP gives away the `k` value to anyone on the path. The compose file in the repository shows how the configuration is supplied, with the image tagged `v2`, a persistent volume mounted at `./data:/var/www/html/data`, and the service listening on port 8080 mapped to 80 in the container.

The `base_url` setting exists because FlashPaper's own URL generation is not always right. The README says FlashPaper will try to generate the secret retrieval URL based on information provided by the upstream webserver, and that this process isn't always 100 percent accurate. If the retrieval URL is wrong for your setup, which the README says usually happens when you are using a reverse proxy upstream, you can manually specify the URL FlashPaper will use. The example given is a `base_url` of `https://foo.com/flashpaper`, which results in retrieval URLs like `https://foo.com/flashpaper/?k=xxxxxxxxxxxxx`. This connects back to the installation step: a reverse proxy terminating TLS is the recommended setup, and it is also the setup most likely to produce a wrong URL, so setting `base_url` is part of the normal path rather than an edge case.

The third step of the traditional installation is the one that looks unusual and is the most important. The README tells you to disable access logging in your web server's configuration so nothing sensitive, and the list is IP addresses, user agent strings and timestamps, is logged to disk. The reason is that the retrieval URL contains the `k` value, which is the ID plus the AES key, so a web server log that records query strings has written the secret to disk in plain sight. This is a genuine operational hazard rather than a theoretical one, and it is the kind of detail that separates a project that has thought about the threat model from one that has not. The same reasoning applies to proxies in front of the application.

The compose file is also the configuration reference

The docker-compose.yml is worth reading as documentation, because the environment block lists every setting the application understands:

yaml
  flashpaper:
    image: ghcr.io/andrewpaglusch/flashpaper:v2
    container_name: flashpaper
    restart: always
    volumes:
      - './data:/var/www/html/data'
    ports:
      - '8080:80'
    environment:
      SITE_TITLE: "FlashPaper :: Self-Destructing Message"
      SITE_LOGO: "img/logo.svg"
      DISPLAY_LOGO: "true"
      DISPLAY_TITLE: "false"
      RETURN_FULL_URL: "true"
      BASE_URL: "" # https://mydomain.com/flashpaper
      MAX_SECRET_LENGTH: "3000"
      ANNOUNCEMENT: ""
      MESSAGES_ERROR_SECRET_TOO_LONG: "Input length too long"
      MESSAGES_SUBMIT_SECRET_HEADER: "Create A Self-Destructing Message"
      MESSAGES_SUBMIT_SECRET_SUBHEADER: ""
      MESSAGES_SUBMIT_SECRET_BUTTON: "Encrypt Message"
      MESSAGES_VIEW_CODE_HEADER: "Self-Destructing URL"
      MESSAGES_VIEW_CODE_SUBHEADER: "Share this URL via email, chat, or another messaging service. It will self-destruct after being viewed once."
      MESSAGES_CONFIRM_VIEW_SECRET_HEADER: "View this secret?"
      MESSAGES_CONFIRM_VIEW_SECRET_BUTTON: "View Secret"
      MESSAGES_VIEW_SECRET_HEADER: "Self-Destructing Message"
      MESSAGES_VIEW_SECRET_SUBHEADER: "This message has been destroyed"
      PRUNE_ENABLED: "true"
      PRUNE_MIN_DAYS: 365

The names make the design legible. `SITE_TITLE` and `SITE_LOGO` set the branding, `DISPLAY_LOGO` and `DISPLAY_TITLE` control whether each is shown, `BASE_URL` is the override for the retrieval URL, and `RETURN_FULL_URL` relates to how the URL is handed back. `MAX_SECRET_LENGTH` is set to 3000, and the `MESSAGES_` prefixed variables are the user-facing strings, which means the interface text can be changed without touching the code. The prune settings are at the end, with `PRUNE_ENABLED` set to true, `PRUNE_MIN_DAYS` at 365 and `PRUNE_MAX_DAYS` at 730. The `data` directory is the only volume, which is what makes the setup simple, since the sqlite database and the static key live there.

Pruning is what stops the database filling up

The prune setting is the one piece of housekeeping, and the README describes it in detail. `enabled` turns on or off auto-pruning of old secrets from the database upon page load. `min_days` and `max_days` control the retention window: when a secret is submitted, a random date and time is generated between those two values in the future, and after that moment has elapsed the secret will be pruned from the database if `enabled` is true. The stated purpose is to prevent the database from being filled with secrets that are never retrieved. One detail is easy to miss: even if `enabled` is false, the prune value is still generated and stored in the database, but secrets will not be pruned unless `enabled` is switched to true. So the random expiry is always recorded, and the switch only controls whether anything acts on it.

There is an API, and it returns the URL in JSON

FlashPaper can accept secret submissions through a simple API, and the retrieval URL comes back in a JSON object. The README's example is a single curl call:

%s

The `json=true` parameter is what switches the response format, and the response is the URL with the `k` value in the query string. That is the whole API surface the README documents, which fits the project's stated focus on simplicity. For a script that needs to hand a secret to someone, this is the interesting part: the caller can post a secret and receive a link without any HTML form in the way. The traditional installation, by contrast, needs PHP 7.0 or later and a web server, and asks you to download the latest release to the document root, copy `settings.example.php` to `settings.php` and edit it.

Where FlashPaper is the wrong tool, and how a password manager differs

Three limits are concrete. The first is that a self-hosted instance is a self-hosted instance. The zero-knowledge property means the operator cannot read the secrets, which is good for the operator's users and means there is no recovery path when something goes wrong: if the static key in the data directory is lost, every unretrieved secret is unrecoverable, and if the sqlite file is lost the same is true. There is no password reset because there is nothing to reset. The second is the delivery channel. FlashPaper gives you a URL, and the recipient still has to get it, and the README is careful that the URL self-destructs after being viewed once, so a link that is opened by a mail scanner, a chat preview or a colleague is consumed before the intended recipient sees it. The third is scope. It shares one secret, once, to one person. It is not a messaging system, it is not a file transfer tool, and it has no notion of a recipient list, so a team that wants to distribute a credential to several people at once is outside what it does.

The realistic alternative for sharing credentials is a password manager with a sharing feature, and the difference in approach is where the trust sits. A password manager holds the secret at rest on a service that can decrypt it, which is fine for a team that trusts that service, and it gives you shared vaults, revocation, rotation and an audit trail, none of which FlashPaper has. FlashPaper moves the trust out of the server entirely and accepts the consequences: no list of recipients, no revocation, no way to check whether the message was ever opened beyond the fact that the link no longer works. Between them sits a middle option, an encrypted file sent over a channel you already trust, which keeps the cryptographic property without the single-use fragility, at the cost of not being self-destructing. For a one-off password sent to one person over a channel you control, FlashPaper is the tidier answer, and for anything ongoing, a password manager is.

Editorial conclusion

FlashPaper fits a team that needs to hand one password or one secret to one person exactly once, over a channel it controls, without leaving a readable copy on a server it does not fully trust. It is the wrong tool for anything ongoing, since there is no revocation, no rotation and no shared vault, and it is the wrong tool when the recipient might open the link from a mail preview rather than the message itself. Before you deploy it, set `base_url` explicitly if you are behind a reverse proxy, disable web server access logging so the retrieval URL is not written to disk, and confirm you can back up the `data` directory, since losing the static key there makes every unretrieved secret unrecoverable.

Frequently asked questions

How does FlashPaper keep the server from reading a secret?

The submitted text is encrypted with a random per-secret AES-256-CBC key and IV, and that ciphertext is encrypted again with a static AES key stored on the server. The per-secret key is returned to the user inside the one-time URL, so the server alone holds only the static key and cannot decrypt anything.

How do I install FlashPaper with Docker?

Download docker-compose.yml from the repository, edit it, run `docker-compose up -d`, and put a reverse proxy in front that terminates SSL/TLS. The published image is `ghcr.io/andrewpaglusch/flashpaper`, and you can also build your own with `docker build . -t flashpaper -f docker/Dockerfile`.

What does the prune setting do in FlashPaper?

When a secret is submitted, a random future date and time is generated between `min_days` and `max_days`. Once that moment passes, the secret is removed from the database on page load if `enabled` is true. The prune value is always generated and stored even when pruning is switched off.

Can I submit a secret to FlashPaper with curl?

Yes. The README shows `curl -s -X POST -d "secret=my secret&json=true" https://flashpaper.io`, which returns a JSON object with the retrieval URL including the `k` value.

Official sources

  1. AndrewPaglusch/FlashPaper on GitHub
  2. Issues
  3. License: MIT
  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/andrewpaglusch-flashpaper.svg)](https://hysenlabs.com/projects/andrewpaglusch-flashpaper)

Was this useful?