Security

heer(ish) · Last updated 18 October 2026

The short version

heer(ish) holds names, photos, travel dates and where people are sleeping. The design assumption is that the page itself is the risk, so heer(ish) starts by collecting less and deleting it on time rather than by defending a large store of data.

This page is Annex II to the standard contractual clauses annexed by the data processing agreement at heerish.app/dpa.

Encryption

Every page is served over HTTPS with TLS 1.2 or better; a request over plain HTTP gets nothing but a redirect to it. The major browsers will not open heerish.app over plain HTTP at all, because the whole .app domain is on their HTTPS-only preload list, and heer(ish) sends HTTP Strict Transport Security as well. Data at rest — databases and photos alike — is encrypted by the hosting platform.

Sign-in links are never stored. Only a SHA-256 hash of the link is kept, so a copy of the database does not let anyone sign in. The optional card PIN is stored as a PBKDF2 hash with a per-card salt, never in the clear.

A notification to an attendee's device is encrypted for the keys that device created (RFC 8291) and signed with heer(ish)'s own key (VAPID, RFC 8292), so the push service that carries it can read neither its title nor its text.

An organiser's second step is stored the same way: an authenticator app's secret is encrypted with AES-256-GCM under a key that is not in the database, recovery codes are kept only as SHA-256 hashes, and a passkey leaves only its public key with heer(ish). A copy of the database does not get anyone past the second step either. The client secret an organisation enters for its Mighty Networks community is encrypted the same way, under a key of its own, and is never sent back to a browser.

Each kind of signed token — the attendee session, the organiser session, the steps between the emailed link and a session, the setup of an authenticator app, and the state carried through a sign-in with Google, LinkedIn, Apple or Mighty Networks — has its own signing key, derived with HKDF from one server secret, so a token issued for one of them is never accepted as another.

Separation and residency

Every event is its own tenant. Each query that reads or changes an event's cards, plans, roster or log is scoped to one event id in the SQL itself rather than filtered afterwards. Three statements name no event: two sweeps that delete expired sign-in links and day-old records of sent links from every event at once, and the daily cap on sign-in links from free events, which counts one organiser's sends across their free events in that region. An attendee session is signed against the event it was issued for, so a session from one event is not a session at another. In the organiser console, an event is looked up only among the events of the organisations the organiser is an admin of.

Events on the EU setting live in a database created under an EU jurisdiction restriction, which the platform enforces. Events on the US setting live in a database created in the United States, in Cloudflare's eastern North America location; no jurisdiction restriction stands behind that. An event does not move between them.

The region an event runs in is shown on its sign-in page and in its privacy notice, so an attendee can check it before they add a card.

Sign-in, and why there are no personal passwords

No organiser and no attendee has a password of their own — so there is no personal password to phish, reuse or leak. Organisers sign in through a one-time link sent to their email address. Every sign-in mail, an organiser's or an attendee's, also carries a six-digit code for when the mail app opens the link in another browser; the code works only in the browser that asked for it, and guesses at it are capped. An event uses one of four modes: a passphrase alone; the passphrase, then a one-time link to the attendee's address; the passphrase, then sign-in with whichever of Google, LinkedIn, Apple and the organiser's own Mighty Networks community the event offers; or, with no passphrase, a link sent only to an address on the organiser-supplied roster. The passphrase is shared by the organiser with everyone they invite and stored only as a PBKDF2 hash; it keeps strangers out but does not prove who someone is.

Organisers then confirm with a second factor: a passkey (WebAuthn, with user verification required and bound to heerish.app, so a lookalike site cannot use it) or an authenticator app (TOTP, each code accepted once, wrong codes locked out). Ten single-use recovery codes cover a lost device; an organiser who has lost everything is reset by hand after their identity is checked another way, never by email alone. Adding or removing a factor, making new recovery codes, inviting an admin or removing another one, and changing an organisation's privacy or data breach contact need a second-factor check from the last ten minutes. A console session that goes unused for 24 hours has ended, and none lasts more than 30 days. The account holder is emailed when a factor is added or removed, new recovery codes are made, a recovery code is used, or wrong codes lock the app's codes (when the lock first engages, and again if the guessing goes on), up to 20 notices a day.

The "Sign out on every device" button on the account page ends every console session the organiser has open and any sign-in still waiting at the second step.

Nobody becomes an admin of an existing organisation except by accepting an invitation while signed in, through both steps, as the address it was sent to; the invitation on its own grants nothing. An organisation, and each admin, can send only a limited number of invitations a day.

Only basic sign-in scopes are requested from an identity provider, and the access token is discarded as soon as sign-in finishes. What is kept is the identifier that provider issues, held against that one event, and, when the provider sends a profile picture, a copy of it in the event's photo storage to prefill the card form; a copy no card uses is erased with the rest of the event on its deletion date. Mighty Networks differs in three ways. Each community is its own sign-in server, connected by the organiser with an app registered in that community, and it issues no signed identity token, so the return is bound to the browser that started it by the signed state, a cookie, PKCE, and a check that the answer names the community the event is connected to. heer(ish) then reads the member's profile in one request with that access token and discards it, along with the refresh token. And the member's email address is kept with the identifier, when the community returns one.

Changing an event's passphrase signs out everyone whose session rests on the old passphrase alone; someone who has since signed in with a link or an account stays in. When an organiser removes an attendee, that person's session ends and the address or account they signed in with cannot sign in to that event again. At a passphrase event the card is the only identity, so changing the passphrase is what keeps a removed attendee out. Taking an address off the roster of a roster event takes its card down and ends its session.

Sign-in attempts and link requests are rate limited. An event opened free of charge mails sign-in links to a limited number of addresses a day, counted per organiser and region rather than per event, because anyone can open one. A wrong card PIN locks that card for up to fifteen minutes rather than locking an address or an IP, so no one can lock someone else out by guessing. Repeated wrong authenticator codes lock that organiser's codes for a minute, then twice as long each time, to at most an hour; passkeys and recovery codes are never locked, so someone holding only the inbox cannot lock the owner out of an account that has either.

Pages, requests and mail

heer(ish) answers at heerish.app and, for an organisation on the annual plan that set one up, at that organisation's own subdomain, which serves only its own events; the console and every sign-in provider's return stay on heerish.app. The hosting platform's default workers.dev address and its per-version preview addresses are switched off, so production is not reachable at a second address and an earlier version of the code does not stay reachable after a fix ships. The deletion job has no web address at all: only its schedule runs it. The hosting platform's log of each request to heer(ish), which would record its full address and with it any sign-in link, unsubscribe link or code from a sign-in provider that address carries, is switched off.

heer(ish)'s pages carry a Content Security Policy. Scripts load only from heerish.app itself and from Google Maps (maps.googleapis.com), and an inline script runs only if it carries the page's nonce. Every page outside the marketing site gets a new random nonce on every response; the homepage and the blog, which are cached and hold nothing anyone typed, share one until the cache refreshes. The policy allows no eval and no plugins; the map may compile WebAssembly and start its workers from blob: URLs, which it needs to run, and an event page may start heer(ish)'s own service worker from heerish.app. Forms can submit only to heer(ish), to Google, LinkedIn and Apple for sign-in, and to Mollie for payment; the pages of an event that offers Mighty Networks also allow the community that event is connected to, at its .mn.co address and at its own domain when it has one.

No page can be shown inside a frame, on another site or on heer(ish)'s own. Pages also tell the browser not to guess a file's type from its content, and switch off the camera, microphone, location, payment and other device features no page uses.

Every page tells the browser to send other sites at most heerish.app as the referrer, never the path or the query. A page whose address carries a sign-in link, an unsubscribe link or a code from a sign-in provider sends no more than that to heer(ish) either. The exception is the bare error page given when an address names no event, or when the service cannot reach its data: it carries none of these headers and not the policy above, and has no script, no link and nothing anyone typed in it.

Every request except GET, HEAD and OPTIONS must carry an Origin header naming the address it was sent to, or it is refused; that is what stops another site from submitting a form in a signed-in person's name. Three addresses are exempt because their senders cannot send that header, and each checks something else instead: one-click unsubscribe from an email acts only on the random identifiers in its link and can only stop mail; Apple's return from sign-in must match the signed state and the cookie of the browser that started it; and after a payment notice from Mollie, heer(ish) fetches the payment from Mollie itself and compares its amount and currency with the order.

Some of what organisers type is printed in mail sent from heerish.app: the organisation's name as the sender, the event's name in the subject, an organiser's name in an invitation, and the organisation's postal address. Each of those, and the name given for an invited admin, is refused at the form when it contains an @, "://", "www." or a domain followed by a path. A bare domain name passes, because organisations are named that way. Line breaks are taken out of the sender name and the subject. Invisible formatting characters, such as direction overrides, are taken out of those two and out of the HTML version of each mail.

The audit trail

Changes to cards, plans, RSVPs and groups are appended to a log: what changed, when, the record as it was before, and which attendee made it. A change made in the organiser console is logged without naming the organiser. Of the changes to the roster, only a removal is logged, by the row's id alone with no copy of the address; an upload is recorded as an attestation, below. The log is append-only and nothing in the product updates or deletes a row in it.

The before-image of a person is written field by field from a fixed list, so the secrets on that row — the PIN hash, the email address, the sign-in identifier — never land in the log.

Statements the organiser confirms — the privacy preset, the lodging warning, a roster upload with its row count and file hash, accepting the data processing agreement — are recorded as attestations with their wording version and timestamp.

Deletion

Every event carries a deletion date, fixed from its retention setting, shown in the console and on its sign-in page. From midday on that date, in the event's own city, its pages answer as if it never existed, and a scheduled job that runs daily at 03:00 UTC deletes it at its next run: the cards, photos, meetups, uploaded lists, sign-in identifiers, notification subscriptions, consent records, unsubscribe records and logs go together.

A deletion run that hits an error emails security@heerish.app, and an event it failed to delete is tried again at the next daily run. A run that finds no earlier run recorded in the previous 36 hours sends the same alert.

The honest number is that live systems are clear within 24 hours of the date and backups are overwritten within 30 days. An email delivered through Resend is the exception: Resend keeps it for 30 days after it was sent, and in its backups for 7 more. What remains afterwards is a receipt with counts per table and no personal data in it, which the console prints as a deletion certificate, and the totals of the event (cards, cards with a place, plans, RSVPs and expected attendance), which the console shows on the deleted event.

An attendee does not have to wait for that date to take a card down. Deleting a card takes it off the event for everyone at once and deletes its photo and its notification subscriptions at once, and hiding a field clears it from the card. The deleted card itself, and the earlier values the log holds, are erased with the rest of the event on the deletion date.

Reporting a vulnerability, and breaches

Write to security@heerish.app with what you found and how to reproduce it. heer(ish) answers within three working days. Please do not test against a live event; ask and a test event will be opened for you.

If a personal data breach affects your events, heer(ish) tells the breach contact of the organisation that runs the event without undue delay and within 24 hours of becoming aware of it, with what is known at that point and the rest as it is established. heer(ish) keeps its own record of every breach, whether or not it was notifiable.

What heer(ish) deliberately does not do

No face templates. No face search, no duplicate detection, no auto-tagging, no third-party cropping that detects faces. A photo is stored and shown, and nothing reads it.

No distance, no proximity sort, no "nearby", no radius filter. Each of those turns the page into a trilateration oracle, and none will be built. Lodging is a named place — a hotel, a district or a city — never a coordinate, and the API never returns anything finer than the precision on display.

No per-attendee analytics, no profiling, and no IP addresses stored. The one third-party script on an attendee page is the Google map, and on the EU and UK settings it loads only after the attendee agrees to it. heer(ish) never emails attendees about itself.

No health-adjacent fields: no dietary, allergy, accessibility or emergency-contact field exists, and the terms forbid putting that data anywhere else on the page.