Form For Later ← Back to the site
Privacy, in plain English

What we do with what you type.

Last updated 31 August 2026 · applies to version 0.1.0

Form For Later is built and published by Plai Faiar LLC, which is the data controller for everything described on this page.

The short parts below are for anyone. If you want to check a claim yourself, every section has a “Show the technical detail” toggle with the exact file and function behind it — so nothing here is just our word.

The short version

FormForLater keeps what you type into a form right there in your browser. There's no account, and nothing is sent anywhere unless you choose to share it — and even then, it's encrypted before it ever leaves your device.

We host one extra thing: the page someone opens to read a share link. It's a plain file, and it can't read your link either — the part of the address that carries your answers and the key is the part browsers never send to a server.

Passwords, card security codes and one-time codes are never written down. Not encrypted, not hidden — never saved, whatever your settings say.

If you sign up for the iPhone launch list, we save your email address, and use it for exactly one thing: telling you when the app is out. More under The iOS waitlist.

What's saved

Everything you save lives in your browser's own storage, on your device. For each answer: what you typed, the question it answered, and the page it came from. There's also a random ID for your browser profile, so your records don't get mixed up with anyone else's on a shared computer — it isn't tied to you personally, and it never leaves your device.

Show the technical detail

Everything lives in chrome.storage.local, the private storage Chrome gives this extension inside your browser profile. Four kinds of thing are kept:

KeyWhat it holds
fk.bundle.<id>a saved record: the answers you saved and how to find those fields again
fk.indexa list of records for the sidebar: title, site, dates, counts
fk.settingsyour settings
fk.deviceIda random identifier for this browser profile

The fk. prefix is left over from an earlier name for this extension. It was kept on purpose: a build looking under new key names would find no records, report none, and start fresh, while everything already saved sat in the profile under the old names — unreachable, with no error shown. The names are invisible in use, and nothing is worth that risk.

Where: src/lib/storage.js — the key names at the top of the file, and putBundle.

What a saved record contains

For each answer you saved: the text you typed and its human-readable form where those differ (the visible text of a chosen dropdown option, for instance), the question's visible label, the heading it sits under, the field's name, id, type and autocomplete attributes, several ways to find that field again on the page, a sensitivity classification, and whether the field was hidden when it was read.

For the page itself: its title, its address, the time you saved it, and a few notes about what was found there — including how many fields were skipped for being too sensitive to keep, and whether part of the page could not be read.

The record also carries a copy of the device identifier described below. It is removed from an export, and it is never in a share link.

Where: src/content/extract.jsextractPage, the object pushed into fields, and the page object it returns, including notes. src/lib/storage.jssaveCapture, which stamps the record with the device identifier.

Alongside those, each saved page keeps a list of the name attributes and visible labels of every form field on it, filled in or not. This is what lets a blank form recognise the record belonging to it.

Where: src/content/extract.jsformShape and formCaptions in extractPage.

A record includes the address of the page you saved it from. Only for pages where you pressed Save. FormForLater does not keep a history of pages you merely visit.

The device identifier

fk.deviceId is a random value generated in your browser on install, using the browser's own cryptographic random number generator. It is not derived from anything about you or your machine, it is not an account, and it is never sent anywhere — it does not appear in a share link.

Where: src/lib/storage.jsgetDeviceId; src/lib/util.jsuid; src/background/service-worker.js — the onInstalled handler. That it is absent from shares can be checked in src/lib/share.jsbuildSharePayload, which lists exactly what a share contains.

What's never saved

Passwords, one-time codes and card security codes are thrown out the moment the page is read, before anything gets written down. Not even the field's name is kept, so nothing left in storage says a password field was ever there.

One exception, so the extension can still be honest with you: it keeps a plain count of how many were skipped on a page, so it can tell you “2 passwords or codes skipped” instead of just going quiet. The count is a number and nothing else.

Show the technical detail

Fields classified critical are discarded during reading, before anything is written. That covers:

  • passwords, passcodes and anything labelled secret
  • one-time codes: OTP, 2FA, MFA, verification codes
  • card security codes: CVV, CVC, CSC
  • any field whose type is password, or whose autocomplete is current-password, new-password or cc-csc

Where: src/lib/sensitive.js — the RULES, TYPE_LEVEL and AUTOCOMPLETE_LEVEL tables; src/content/extract.js — the critical check in extractPage, which stops before the field is added.

Nothing that identifies such a field is kept — not the value, not its name, not its label. They are also left out of the field lists described under What's saved, so a raw dump of storage contains no trace of them.

One exception, stated for accuracy: the record keeps a count of how many fields were skipped this way on each page. The count is a number and nothing else — no name, no label, no value, nothing that says which fields they were.

Where: src/content/extract.js — the critical check sits before shape.add, and increments notes.skippedCritical.

If a field of this kind ever reaches a record — through an older record, or a classification rule that changed between versions — it still cannot be put into a share link, whatever the settings say and whatever you switch on for that field.

Where: src/lib/storage.jsresolveField.

What happens when you share

Nothing is shared until you press the button. You pick exactly which answers go in, one at a time, and the rest are left out completely — not hidden, just not there. Everything is encrypted on your device before the link is even built, using AES-256-GCM, with a key generated fresh each time.

By default the whole encrypted message travels inside the link itself, in the part browsers never send to a server. That means nothing is uploaded anywhere: the link is the data, and whoever's holding it can open it in their browser without any server being involved at all.

If you turn on the optional relay, the encrypted bytes get uploaded so the link can be short and can really expire. The key that unlocks it never goes along for that ride, so whoever runs the relay only ever holds bytes they can't read.

Show the technical detail

A share link needs a page for the recipient to open, and one is filled in for you so the feature works without any setup:

https://share.formforlater.com/share.html

That is a copy of server/share.html, on a domain of ours, served as a static file. Which company operates that server today, and exactly what it can and cannot see, is set out under link-only mode below. You can replace it with your own copy at any time, and clearing the field turns sharing off — the extension then refuses to build a link rather than falling back to anything.

Where: src/lib/storage.jsSHARE_VIEWER_URL and DEFAULT_SETTINGS; src/lib/share.js — the first lines of createShare, which throw when the address is empty.

In both modes, fields you have marked “don't share” are removed from the data before it is encrypted, not hidden afterwards. They are not in the link in any form.

Where: src/lib/share.jsbuildSharePayload, which filters before sealJson is called.

What a share link carries

For each field you left switched on: the visible label, the heading it sits under, the value, its human-readable form where those differ, what kind of field it was, and its sensitivity level. Alongside those, the record's title, the site's hostname, when it was saved, the expiry you chose, and the address of each page.

Two of those you can switch off in the share dialog before you create the link, and the count shown there is built from the same call that builds the payload, so the number you read is the number you send:

  • Which steps go in. A record holding several pages of a form offers one checkbox per page. Unticked pages are absent from the encrypted data, not hidden in the viewer.
  • Whether the page addresses go in. “Include the address of each page” starts ticked; unticking it sends nothing in place of every address.

Fields you switched off are excluded on every step either way.

Unticking the addresses does not hide which site it was. The record's hostname is part of every share link and there is no control that removes it — the recipient needs to know what they are looking at. Each page also carries a short identifier derived by hashing its address, used to tell one step of a form from another. If you need somebody to see answers without knowing the service they were typed into, a share link is the wrong tool.

Where: src/lib/share.jsbuildSharePayload, which lists every field that goes in and drops both switchable items before sealJson; ui/viewer.jsrenderShareForm, which owns the checkboxes and passes pageKeys and includeUrls to createShare. Asserted end to end against decrypted output in test/suites/share.js.

Everything is encrypted with AES-256-GCM using a key generated fresh for that link, in your browser.

Where: src/lib/crypto.jsgenerateKey and sealJson.

Link-only mode (the default)

The encrypted data and the key both live in the part of the link after the #. Browsers never send that part to any server, so nothing is uploaded. The link is the data.

Where: src/lib/share.jsfragmentLink.

Two consequences worth being clear about:

  • Anyone who has the link can read it. Forwarding the link forwards the data.
  • The expiry date is a request, not a lock. The viewer page checks it and refuses to display anything afterwards, but the encrypted data is already in the recipient's hands and the extension cannot take it back. The interface says this too.

Where: server/share.html — the expiresAt check in main.

What the share page host can and cannot see

When the recipient opens the link, their browser fetches the viewer page from wherever it is hosted — by default, from us. It is a static file: there is no code of ours running on that host, no database, and nothing that can be queried after the fact beyond whatever the host logs about serving a file.

formforlater.com is our own domain, and at the time of writing it points at GitHub Pages — so today GitHub operates that server and keeps whatever logs it keeps, not us. The domain is ours precisely so that this can change without breaking links people have already sent; if the host changes, this section is updated with it.

It can see, as any web server does when a browser asks it for a file: that someone requested that page, their IP address, the time, and their browser's user agent.

It cannot see your answers, the labels, the site the form was on, or the key. All of that is in the part of the link after the #, and browsers never put the fragment in an HTTP request — not in the request line, and not in a Referer header. That is browser behaviour, not a promise of ours. The page then decrypts in the recipient's browser, using a key that reached it through the address bar and is never sent back out.

  • The extension itself never contacts that host at all. In link-only mode building a link is pure string assembly on your device. The only browser that ever requests the page is the recipient's, when they open the link.
  • We cannot tell who is sharing. Nothing in the request identifies you, the record, or the extension; the device identifier is not in a share link at all.

The page is a plain file you can read, and you do not have to take ours: put your own copy on any static host and paste its address into Settings, and no request goes anywhere near us.

Where: src/lib/storage.jsSHARE_VIEWER_URL; src/lib/share.jsfragmentLink, which builds a string and makes no request; server/share.html — the whole file, whose only fetch is the relay branch described below and is not reached at all by a link-only link.

Relay mode (optional)

If you deploy a relay and enter its address, the encrypted data is uploaded to it and the link carries a short identifier instead. The key stays in the fragment and is never part of the upload.

Where: src/lib/share.js — the relay branch of createShare, whose request body contains only the ciphertext, the chosen lifetime and the one-time flag; and relayLink, which puts the key after the #.

We do not run a relay. There is no default address; the setting starts empty and stays empty until you fill it in. If you use one, it is one you or someone you chose deployed.

We host the share page because it only ever serves a file and can therefore learn nothing. A relay receives an upload, so running one would mean holding other people's ciphertext — and we'd rather not hold it at all than explain why holding it is safe. The share page also needs its own copy to work with a relay: the one we host has no relay address in it, so a relay link opened there is refused rather than fetched.

Where: src/lib/storage.jsDEFAULT_SETTINGS, where relayUrl is ''; server/share.htmlRELAY_URL, empty in the published copy, and the “Not set up” branch in main.

A relay you deploy can see: the encrypted blob, the lifetime you chose, whether you asked for one-time delivery, the random identifier it generated, and — as any web server does — the IP address and request headers of whoever uploads or downloads. It cannot see your answers, the labels, the site, or the key. It stores bytes it has no way to interpret, because the key never reaches it.

Where: server/worker.js — the /put and /get/ handlers, which read only payload, ttl and oneTime and never touch a key.

The relay deletes on a timer, and a one-time link is deleted before the content is handed over, so a failed delete cannot serve it twice.

Where: server/worker.jsexpirationTtl on the write, and the delete that runs before the response.

What leaves your device

Almost nothing, on its own. FormForLater's code runs on the pages you visit so it's ready to read a form the moment you ask it to, but it doesn't look at anything or send anything until you click Save or Restore. In the whole codebase there's exactly one place that makes a network request, and it only runs if you've set up the optional relay yourself.

No analytics, no crash reports, no telemetry, and no code loaded from the internet while it runs — every script it ever executes ships inside the package you installed.

Show the technical detail

FormForLater's reading code is loaded into every page you open, including frames inside those pages. It defines functions and waits; it does not read your form, and sends nothing, until you click the toolbar icon or press the keyboard shortcut.

Where: manifest.json — the content_scripts block; src/content/main.js, which only registers a message listener. Reading happens in src/content/extract.jsextractPage, which nothing calls until the popup or the shortcut asks it to (ui/popup.jsscan; src/background/service-worker.jssaveTab).

The extension makes one outbound network request in its entire codebase, described under What happens when you share. There is no analytics, no telemetry, no error reporting, and no remote code: every file it runs ships inside the package.

Where: searching the packaged extension for fetch(, XMLHttpRequest, sendBeacon, WebSocket or EventSource returns a single result — src/lib/share.js, in createShare. The build refuses to produce a package where that count is anything other than one (scripts/pack.js), so this claim cannot quietly stop being true.

The iOS waitlist

If you sign up at formforlater.com/waitlist for the iPhone launch, we save your email address. That's the only thing collected there — no name, no other details, nothing about the browser or device you signed up from.

We use it for exactly one thing: telling you the day the iOS app is out. We won't use it for anything else, and we won't send you anything else. You can ask to be taken off the list at any time, and every email we send will include a way to do that.

To ask right now, email support@plaifair.ai from the address you signed up with, or use the support page. Your address is deleted outright, not marked or archived.

Show the technical detail

The waitlist form posts to a small Cloudflare Worker backed by a key-value store. The address itself is the key; the only value stored alongside it is the time you signed up. Nothing else about you is written down — not your IP address, not your browser, not where you came from.

Where: server/site/waitlist.html — the submit handler, which posts { email } and nothing more; server/waitlist-worker.js — the /join handler, which stores only the address and a timestamp.

Two things run alongside that, purely to keep the list clean, and neither becomes part of your record: a hidden field that a real person never fills in (a honeypot — if it has anything in it, the submission is quietly ignored), and a short-lived count of recent attempts from the same IP address, used only to slow down obvious spam. That count expires on its own within the hour and is never linked to any email address.

Where: server/waitlist-worker.js — the honeypot check and the rl: rate-limit keys, both in the /join handler.

Removing an address deletes it outright rather than marking it somehow — there's no record left that it was ever there.

Where: server/waitlist-worker.js — the /leave handler.

Exporting

Two buttons in the record viewer take a record out of the extension on purpose, and they don't behave the same way.

Export file saves the whole record to your computer as a plain JSON file, ignoring your “don't share” marks — it's a full copy for you, not something built for someone else to read. Passwords and codes are still absent, because they were never stored to begin with. Your device ID is stripped out first, so an exported file can't be tied back to your browser.

Download PDF, next to Create share link, builds a document laid out for reading and filing — the record's name, the site, the date, then each page as a section with its questions and answers. It's built from the exact same choice a share link is: a field you've marked “don't share” is left out of the file entirely, the same way it's left out of a link, and passwords and codes are absent for the reason they always are. The document says on its own first page that it holds real answers and who it's safe to hand to.

Neither file is encrypted, and once one is on your disk, FormForLater isn't protecting it anymore — that's on whatever you do with it from there.

Show the technical detail

Where: ui/viewer.js — the exportBtn handler, which removes deviceId and serialises everything else.

The PDF is built from the same redacted object a share link is built from — FFL.share.buildSharePayload — so a field excluded from one is excluded from the other by construction, not by a second check written to agree with the first. The PDF itself has no library behind it: src/lib/pdf.js writes the file's bytes directly, using only the PDF format's own built-in Helvetica font, so nothing is fetched and nothing is built. Every character is real, selectable text; nothing is ever drawn as a box over a value, because a black rectangle in a PDF routinely leaves the covered text selectable underneath it.

Where: ui/viewer.js — the pdfBtn handler; src/lib/pdf.jsbuildRecordPdf.

Uninstalling

Removing the extension deletes everything it saved. That's just how Chrome handles extension storage, not something we built — there's no separate copy anywhere else to delete, because there never was one.

You can also delete records yourself any time: one at a time in the viewer, or all of them from Settings. Two things stick around on purpose: links you already sent (the data's inside the link itself, not on a server, so removing the extension doesn't touch it) and files you exported.

Show the technical detail

Where: src/lib/storage.jsdeleteBundle; ui/options.js — the wipe handler.

Relay entries do expire on their own, whether or not the extension is still installed.

What we never do

  • No account, no sign-in, no profile.
  • No analytics, telemetry, crash reporting or usage measurement.
  • No advertising, and no sale or sharing of your data with anyone.
  • No code loaded from the internet at runtime.
  • No request goes anywhere except the single upload under the optional relay — and only when you press the button — or an email address, and only if you choose to join the iOS waitlist.
  • No request of any kind to the share page we host from the extension itself. Only a recipient's browser does, when they open a link you sent them.

Changes

When something here changes because the extension changed, we update this page in the same release. The date and version at the top say which one it describes.