Home›Secreta›Manual
Secreta · Manual

Secreta Manual

Secreta — User Manual

One-time, burn-after-reading links, on your own server. Version 4.1.3 [src: app/controllers/api.php:30].

About this manual

Every statement here was written by reading Secreta's own source, and each one carries the file it came from in a bracketed src marker. Paths are relative to the folder this docs directory sits in. If a sentence disagrees with the application, the application is right and this manual has a bug — the markers exist so you can check rather than take our word for it.

Nothing is described that the code does not do. Where something is deliberately limited, that is said plainly rather than left out — which matters more here than in most products, because the subject is confidentiality.

The zero-knowledge contract

This is the whole product, and it is worth reading carefully.

The decryption key is generated in the browser and travels only in the URL fragment — the part after #, which browsers never send to the server. The server therefore cannot decrypt a secret; it only ever handles ciphertext [src: app/src/Secret.php:7].

That is where the confidentiality comes from. Not from the hosting, not from the database, not from trusting the operator.

The second layer, and what it is not

The server also encrypts the stored ciphertext at rest, under a key local to the installation [src: app/src/Secret.php:12].

The source is careful to say what that is for and what it is not: it protects a stolen database file, but it is not the source of confidentiality — the browser key the server never sees is [src: app/src/Secret.php:13].

Being clear about that distinction matters. An installation with at-rest encryption but a compromised server would still be safe, because the key was never there to steal.

The same primitive on both sides

The server's AES-GCM helpers use exactly the primitive the browser does — AES-256-GCM with the 16-byte tag appended to the ciphertext — so a blob produced by either side decrypts on the other [src: app/src/Secret.php:17].

Links

A share token is 18 random bytes rendered URL-safe, about 144 bits of entropy [src: app/src/Secret.php:26]. It is meant to be unguessable rather than memorable.

A new link can be shown as a QR code for handing a secret across a desk. The code is drawn in the creator's browser by the bundled qr.js from the link itself, key included, and nothing about it is sent to the server [src: app/assets/secreta.js:345]. Anyone who can photograph the screen can read it, exactly as with the link.

Ciphertext larger than roughly 192 KB of plaintext is refused [src: app/src/Secret.php:28]. The nonce must be exactly 12 bytes — anything else did not come from this product's own client [src: app/src/Secret.php:30] — and a blob shorter than the GCM tag plus one byte cannot be real [src: app/src/Secret.php:32].

One link per person (3.3.0)

Signed in, + One link per person on the share page takes a list of names, one per line (up to 20; blank lines and repeats are dropped). The browser encrypts the secret separately for each person, so every link has its own key and its own views [src: app/assets/secreta.js:247], and the links are made one after another; if one is refused, the links already made are still shown and the page says how many were made [src: app/assets/secreta.js:284]. Each name is stored with its link as your note of who it went to — never the secret, which the server still cannot read — and Secrets shows it beside the link, so you can see whose has been opened and whose has not. The person opening a link never receives the name. Only a signed-in sender or an API key's owner can name a link, and a name is at most 120 characters [src: app/controllers/v3.php:219]. Names are cleared 60 days after the link was made [src: app/controllers/v3.php:152]. The REST API and the MCP tool take the same optional label.

Files (3.2.0)

A secret can carry a file — a certificate, a key file, an .env. It is off until an administrator switches it on under Settings → Files [src: app/src/SecretFile.php:44], and capped: 5 MB by default, up to 25, and never above what PHP on your server accepts in one request; the share page shows the cap in force [src: app/src/SecretFile.php:38].

The zero-knowledge contract holds unchanged. The browser encrypts the file with the secret's own key under a nonce of its own [src: app/assets/secreta.js:84], and the file's name and type travel inside the encrypted message, so the server holds bytes it can neither read nor name. Before encrypting, the browser removes photo metadata: from a JPEG the EXIF, XMP and IPTC blocks and comments (location, camera, author), from a PNG its text, EXIF and time chunks; other files go as they are [src: app/assets/secreta.js:165]. The server refuses a file that shares the text's nonce or is too short to be AES-GCM output [src: app/controllers/v3.php:212].

On the server the encrypted file is encrypted once more at rest with the install key and written to data/files/, outside the database, readable by the app alone [src: app/src/SecretFile.php:47]. It is handed out with the view that consumes it and overwritten with random bytes and removed with the text [src: app/controllers/app.php:30]; an expired secret nobody opened loses its file the next time anyone creates one [src: app/controllers/v3.php:944]. The recipient's browser decrypts it and offers Save; it is always saved as a download, so a sender's .html can never open as a page on your site [src: app/assets/secreta.js:505].

From a terminal (3.2.0)

app/bin/secreta-send shares a secret from the command line. Copy the one file wherever you need it (it needs PHP 8 with openssl), set SECRETA_URL to your Secreta and SECRETA_KEY to an API key, and pipe the secret in: secreta-send < password.txt. It reads the secret from standard input, never from the command line that other users of the machine can see [src: app/bin/secreta-send:61]; --views, --hours, --file with --note, and --passphrase work as in the browser.

It encrypts on your machine exactly as the browser does — AES-256-GCM, and with a passphrase the link carries its own key while the data key is wrapped by PBKDF2-SHA-256 over the link key and the passphrase, 150,000 rounds [src: app/bin/secreta-send:86] — sends only ciphertext to POST /api/secrets [src: app/bin/secreta-send:96], and prints the link with its key. The suite opens what it sends with the browser's own decryption code.

Requests: asking someone for a secret

Since 3.2.0 a team member can ask someone outside — a client, a supplier — for a password or key, and it stays zero-knowledge in the other direction too. Requests → Request a secret: say what you are asking for, choose how long the link works, and the browser makes an ECDH P-256 key pair. The server stores only the public half [src: app/controllers/requests.php:62]; the private half goes into your private link, after the #, which is shown once and stored nowhere. You get two links: the one you send, and the one you keep.

The person you ask opens the link with no account, sees what you asked for, and types the answer. Their browser makes a one-off key pair of its own, derives a shared AES-256-GCM key with your public key (HKDF-SHA-256, salted with the request's token), encrypts, and sends the ciphertext with its own public key [src: app/src/Request.php:29]. The server checks it looks like encrypted output, stores it encrypted a second time at rest, and accepts one answer per link: a second is refused, and so is anything shaped like plaintext [src: app/controllers/requests.php:100], [src: app/src/Request.php:129].

You open it from your private link, signed in: the page first checks that the key in the link belongs to this request — a key from another request is refused before anything is spent — then fetches the answer once and decrypts it in your browser; the stored copy is destroyed as it is handed over [src: app/controllers/requests.php:134]. Only the person who made the request can open it. Withdrawing a request stops its link and destroys any answer [src: app/controllers/requests.php:152]; an expired request does the same on its own, and the sharing policy's longest life applies to request links too (a request always ends: "never" becomes 30 days). Made, answered, opened and withdrawn are in the admin trail, with no content, and a request.answered webhook can tell your chat.

Templates keep the asks you send again and again, a client onboarding for instance: tick "Save this ask as a template" when you make a request, and it keeps what you asked for and how long the link works [src: app/controllers/requests.php:81]. Start from a template pre-fills the form; each use still makes a new request with its own key pair. Templates are shared by the team and listed on the Requests page; the person who saved one, or an administrator, can remove it [src: app/controllers/requests.php:177]. A template holds the wording the person you ask will read, never a secret.

Lose the private link and the answer cannot be opened — by you, an administrator or the server. Make a new request. The protocol is written out in PHP as well (Request::seal() / Request::open()), the reference the browser is tested against [src: app/src/Request.php:102], [src: app/src/Request.php:114].

Expiry and view limits

Expiry is chosen from a fixed set of presets: never, 1 hour, 6 hours, 1 day, 3 days, 1 week, 30 days [src: app/src/Secret.php:75]. Anything else falls back to a day [src: app/src/Secret.php:97].

A secret burns once its view count reaches its limit, at which point the ciphertext must be destroyed [src: app/src/Secret.php:146].

When ciphertext is deleted it is first overwritten with random noise of the same length [src: app/src/Secret.php:259].

The install's policy, and the trap inside it

An administrator sets ceilings — a maximum lifetime, a maximum number of views, and a view count above which a passphrase becomes mandatory. Every creator is clamped to them [src: app/src/Secret.php:93].

⚠️ Zero means opposite things on the two sides

The source flags this as "the zero trap", and it is worth understanding because it is the kind of bug that would be invisible [src: app/src/Secret.php:85]:

In a request, 0 means "never expires". In a policy, 0 means "no ceiling". Opposite ends of the same scale, spelled the same way.

So an unlimited request under a finite policy has to become the policy's ceiling rather than sailing through as the smaller number. Getting it backwards would make "never" the one lifetime that escaped the policy entirely [src: app/src/Secret.php:88].

The implementation says so explicitly: never (0) is unlimited, so it always exceeds a finite ceiling [src: app/src/Secret.php:102]. A clamped expiry becomes the largest offered preset within the ceiling, and that function never returns "never" [src: app/src/Secret.php:130].

Clamping versus refusing

A lifetime or a view count over the ceiling is quietly clamped, and you are told which fields were adjusted [src: app/src/Secret.php:93].

A missing mandatory passphrase is refused, not downgraded [src: app/src/Secret.php:116], and the reasoning is good: quietly turning a five-view link into a one-view link would strand four recipients with a dead link [src: app/src/Secret.php:117].

What is logged

Metadata only. The audit entry holds a secret id, an action, a hashed address and a timestamp — and the source states that no field in the array or the table could ever hold secret content [src: app/src/Secret.php:247].

Actions are limited to a fixed set, and an unknown one throws rather than being recorded [src: app/src/Secret.php:34].

The raw IP address is never stored. It is hashed one-way with a per-installation salt [src: app/src/Secret.php:211].

The stored row is likewise ciphertext and metadata only — never plaintext, never the client key, never the passphrase [src: app/src/Secret.php:221].

Who can do what

Creating a secret is a public action by design, so roles do not govern it [src: app/src/Perms.php:5]. What roles govern is the administrative surface, and the source names the asset as the installation's own exposure [src: app/src/Perms.php:6]:

  • require_login_to_create decides whether anyone on the internet may store blobs on your server. Turning it off turns a team tool into an open relay [src: app/src/Perms.php:8].
  • The sharing policy is the ceiling every creator is clamped to; raising it silently lengthens the life of every secret made afterward [src: app/src/Perms.php:10].
  • "Reset all data" destroys every outstanding secret, colleagues mid-delivery included [src: app/src/Perms.php:12].

When the setting is on and nobody is signed in, creation is locked [src: app/controllers/app.php:36].

This table was produced by executing the product's own permission function against every permission the routes ask for.

Permissionviewermemberadmin
log.viewyesyesyes
secrets.create—yesyes
secrets.burn—yesyes
settings.view / settings.manage——yes
team.view / team.manage——yes
audit.view——yes

Note that log.view — the metadata log — is available to a viewer, while audit.view is not. Grants below admin are enumerated rather than wildcarded [src: app/src/Perms.php:18].

⚠️ Backups follow the model here

Worth stating because several products in this family do not: Secreta's database backups are permission-gated, requiring the settings permission rather than a bare sign-in [src: app/controllers/api.php:181]. A viewer cannot download the database.

What the JSON backup carries for a secret. It blanks sign-in hashes, two-factor seeds, webhook and single sign-on secrets and open request links [src: app/controllers/v3.php:641], and keeps each live secret's row, so a restore brings live links back. That row opens nothing on its own. The text is encrypted in the sender's browser under a key that exists only in the link, then encrypted again under the server's key, which lives in the config file rather than the database [src: app/src/Secret.php:193]. For a passphrase secret, the stored wrapped key is locked with a key made from the link's key and the passphrase together [src: app/assets/secreta.js:61] [src: app/bin/secreta-send:86], so a backup gives nobody a way to try passphrases without the link. The tests hold this against a real backup [src: app/tests/file_check.php:144].

The API and agent access

Only two tools are exposed to an agent [src: app/controllers/v3.php:78]: creating a secret, and statistics.

There is deliberately no tool that reads a secret, and the reason given is exactly right: reading burns it, and an agent consuming the recipient's view would destroy the delivery [src: app/controllers/v3.php:75].

An agent creating a secret must do the encryption itself. The endpoint accepts ciphertext only — AES-256-GCM, base64 of ciphertext with the 16-byte tag, plus the base64 12-byte nonce — and the agent appends # and its own base64url key to the returned share URL [src: app/controllers/v3.php:73].

That is the zero-knowledge contract extended to automation rather than quietly suspended for it.

Data requests: a person a link was named for (4.1.1)

A link made for a named person keeps that name as your note of who it went to, and every opening is logged with a salted hash of the address it came from. An administrator opens Data requests (under Set up) and picks the name:

  • Download their data writes one JSON file listing each link made for them (when, until when, how many views, opened or not) and its openings. It never contains a link's address or the secret [src: app/controllers/v3.php:986].
  • Erase their name clears the name from every link made for them and the hashed address each of those links was opened from; the action and the time of each opening stay [src: app/controllers/v3.php:967]. The links keep working exactly as before, so nothing is refused [src: app/controllers/v3.php:1008]. The admin trail records that an erase happened and how many links it touched, never the name.

Names are also cleared on their own 60 days after a link is made; that sweep runs whenever a secret is made, and when this page is opened.

What Secreta does not do

It cannot recover a secret for you [src: app/src/Secret.php:7]. The server never had the key. If the link is lost, the secret is gone, and no administrator can help.

It does not read secrets on an agent's behalf [src: app/controllers/v3.php:75], because reading is destructive.

It does not store your IP address [src: app/src/Secret.php:211], your plaintext, your key or your passphrase [src: app/src/Secret.php:221].

It does not protect you from a compromised recipient. Once somebody has opened the link, what they do with the contents is outside the product entirely.

It does not keep files in the backup. The database backup carries a secret's row, not its file in data/files/; like the secrets themselves, files are meant to be short-lived.

It does not preview files. The recipient saves the file and opens it with their own software.

At-rest encryption is not the guarantee [src: app/src/Secret.php:13]. It protects a stolen database file. The browser key the server never sees is what protects the secret.

Looking after your install (from 4.0)

Updates from inside Secreta

Updates in the menu opens the Updates page (/updates) [src: app/views/updates.php]. Paste your license key once and press Save key: either key works, the one in your Lemon Squeezy receipt or the one on your order page (it starts with OWX-). Check for updates asks the store for the newest release your license covers; Secreta never checks by itself.

When a new version is ready, Back up and update to that version backs up your data and the current version, installs the new one and checks it. If the update is interrupted, or the new version's database step or that check fails, the previous version is put back by itself. Secreta is unavailable for about a minute while this runs. The update replaces Secreta's files, including any you edited, except .htaccess, .user.ini and robots.txt: an edited copy of those is kept, with the new version's copy beside it as .new to compare and merge. The Health page lists any file that differs from the version installed, so you can see that before you update. A copy of the previous version (data/updates/code-<version>/) and a backup of your data from just before the update stay in data/updates/ [src: app/src/Updater.php]. If Secreta shows an error page after an update anyway, that is your way back: copy the files from code-<version>/ over the app's files, then restore the backup on the backup page. Updating from the page needs PHP's sodium and Phar extensions; if one is missing, the Updates and Health pages say so and updates stay by hand. Where Secreta is hosted for you, the host updates it and the page says so.

Reaching 4.0 from a 3.x version is the last update done by hand: replace the files as before (config.php and data/ stay, and the database migrates itself on the next page load). From 4.0 on, the Updates page does it.

Starting with sample data

The installer offers Start with sample data: sample secret records (none of them can be opened) to try things on. You can remove it in one click. [src: app/install/index.php:116]. It is ticked by default; untick it to start empty. The sample is added when you open the first page after the installer.

When you are done with it, press the remove button in the sample banner. It asks "Remove the sample secret records? Your own records stay." Only the sample goes: your own records stay, and a sample record that one of your own records uses is kept and named in the report [src: app/src/SampleData.php].

Backups by themselves, and a copy somewhere else

On the backup page (/restore, linked from Settings), Back up once a day by itself is on by default; untick it to stop [src: app/views/restore.php:137]. The first visit at least 24 hours after the last backup starts the next one, after the page has loaded, so no cron job is needed. The newest 14 stay in data/backups/, with credentials left out of them (a restore keeps the live values for those). Back up now takes one straight away.

To keep a copy somewhere else as well, fill in an S3-compatible bucket (Backblaze B2, Wasabi, AWS S3 or your own MinIO): the bucket address (an https endpoint), the region, the bucket name, an optional folder, and the access key ID and secret key. Leave the address empty to keep backups on this server only. The copy goes only to the bucket you set; nothing is sent to Ownware. Once a week Secreta reads the newest backup back through the restore's own dry run and keeps the result for the Health page [src: app/src/BackupTargets.php].

On Nginx: keep data/ private

On Apache, the .htaccess that ships with Secreta keeps data/ (your backups included), src/, config.php and the other private files off the web. Nginx does not read .htaccess, so add these three blocks to your server block, above the block that passes .php files to PHP (Nginx uses the first one that matches):

location ~ ^/(src|data|bin|tests|controllers|deploy|cron)/ { deny all; }
location ~ ^/views/.+\.php$ { deny all; }
location ~ (^|/)(config\.php|\.demo-mode)$|\.sqlite$ { deny all; }

Then open /config.php in a browser: it should answer 403. Only the template files under views/ are closed, not the folder: the app's own saved-view addresses start with /views/ too, and they must reach it.

A complete server block for a new site (PHP-FPM 8.3; change the name, the folder and the socket to yours):

server {
    listen 80;
    server_name example.com;
    root /var/www/secreta/app;
    index index.php;

    location ~ ^/(src|data|bin|tests|controllers|deploy|cron)/ { deny all; }
    location ~ ^/views/.+\.php$ { deny all; }
    location ~ (^|/)(config\.php|\.demo-mode)$|\.sqlite$ { deny all; }

    location = /install/ { rewrite ^ /install/index.php last; }
    location / { try_files $uri /index.php?$query_string; }

    location ~ \.php$ {
        try_files $uri =404;
        fastcgi_pass unix:/run/php/php8.3-fpm.sock;
        include fastcgi_params;
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
    }
}

The Health page

Health in the menu (/health) shows, one line each, whether the install is looking after itself: the PHP version and extensions, disk space, whether data/ is writable, email, the age of the last backup, the offsite copy, the weekly check, whether an update is waiting, and any file that differs from the version installed. Each line says what to do when it is not fine. The page only reads: it never changes anything and never contacts anything [src: app/src/Health.php].

Connect it to other tools (Zapier, Make, n8n)

Secreta sends a webhook the moment something happens and accepts calls to its API, so Zapier, Make and n8n can connect to it without a plugin. The steps, the events and example recipes are in API.md, in the section "Connect it to Zapier, Make or n8n"; the API page of Secreta on the store shows the same file [src: app/API.md].

New in 4.1.2: sign-in limits

New in 4.1.2 (security update): Two-step codes are now limited (10 wrong per person or per address in 10 minutes), and wrong passwords count per account as well as per address. An installed app whose database does not answer keeps its web installer closed instead of showing the install form.

Wrong passwords are counted per address and, since 4.1.2, per account too (the email typed, in any case, stored only as a hash). After 10 wrong passwords in 10 minutes the sign-in at /login answers "Too many attempts — wait a few minutes and try again." for that address, and for that account from any address. A right password clears both counts [src: app/src/LoginRate.php:40].

Wrong two-step codes (for people who turned two-step sign-in on under /security) are counted per person and per address. After 10 wrong codes in 10 minutes the code step answers "Too many wrong codes. Wait ten minutes, then sign in again. A recovery code works in place of the code." even for the right code, and the half-finished sign-in is dropped, so the password is asked again [src: app/src/LoginRate.php:91]. The right code clears that person's count; an address's count runs out ten minutes after its wrong codes. Before 4.1.2 the code step had no limit.

If you are locked out: wait ten minutes, then sign in again with your password and a current code, or one of your recovery codes (each works once). If you lost both the authenticator and the recovery codes, whoever runs the server can turn two-step sign-in off for you in Secreta's database (by default the SQLite file in data/, or your MySQL database): UPDATE users SET totp_secret = '' WHERE email = 'you@example.com'; Then sign in with your password and turn it on again under /security.

The web installer stays closed when the database does not answer: with config.php in place, /install now answers 503 "This app is already installed (config.php exists), but its database is not answering, so the installer stays closed." and writes nothing until the database answers again. Before 4.1.2 a database outage (or a missing SQLite file) showed the install form there, and whoever found it could set up a new administrator. To install again from scratch, remove config.php first [src: app/install/index.php:20].

New in 4.1.3: security update

New in 4.1.3 (security update): no other site can put Secreta's pages in a frame; the /mcp address refuses requests from other sites' pages; company sign-in is stricter; and the audit CSV treats one more first character as text. Nothing else changed: no feature, screen, setting or database table, and your data, config.php and data/ are not touched.

No other site can frame these pages. Every page now answers Content-Security-Policy: frame-ancestors 'self' and X-Frame-Options: SAMEORIGIN, so a page on another site cannot show Secreta in a hidden frame and trick a signed-in person into pressing a button [src: app/index.php:4]. The installer answers frame-ancestors 'none' and X-Frame-Options: DENY [src: app/install/index.php:4]. If you had Secreta framed inside another site of your own, that frame now stays empty; link to it instead.

/mcp refuses other sites. A request whose Origin header names another site gets 403 before anything is read. A desktop or server client sends no Origin and is served as before, and so is a page at Secreta's own address [src: app/src/Mcp.php:272].

Single sign-on. The sign-in now uses PKCE (S256), so an intercepted sign-in code is of no use on its own; a sign-in that comes back to a different browser session is asked to start again [src: app/src/Oidc.php:228]. With Google as the provider, a company address needs a Google Workspace account of that same domain (the token's hd claim), and Google must report the address as verified, so a personal Google account made with a work address no longer gets in [src: app/src/Oidc.php:215]. Gmail addresses work as before, other providers are unchanged, and password sign-in is not affected.

The audit CSV. A cell that starts with a carriage return now opens in a spreadsheet as text, as cells starting with = + - @ or TAB already did [src: app/src/AuditLog.php:138].

Docker. The Dockerfile now installs PHP's zip extension, so a Docker install can make the backup with files [src: app/Dockerfile:7].

Also changed, with no effect on a running install: the corner card on a public demo offers only the purchase, and API.md no longer shows the config example of a product we retired.

← Back to Secreta · Quickstart · API · Test run