A client portal with no account

Last updated: · By Radim Sekera

Your client gets an email, taps a link on their phone, and is looking at a page asking for their logo files. No password to invent, no "confirm your address" loop first, no app to install. They upload three photos, paste a URL, close the browser. If they come back the next day from the same phone to finish the last item, the page still remembers who they are and what they already sent.

That experience — open a link, do the thing, leave — is the design goal. Getting there without an account means the portal still has to answer every question an account would normally answer: who is this, are they allowed to see this particular intake, and what stops someone else from reading it. This page is about how BriefGate answers those without asking the client to sign up, and what that choice costs.

What the client experiences

  1. You call define_intake. BriefGate creates a portal at a unique slug (https://p.briefgate.dev/8f3kqmr2) and emails the client an invitation.
  2. The email's link is that portal URL with a token appended (?t=...) — that token is what actually gets them in. The bare portal_url shown in the API response and the dashboard does not carry it; on its own it is not a working link.
  3. The client clicks it. The browser exchanges the token for a session cookie and lands them in the portal — see Client Portal.
  4. They fill in items and upload files, closing the tab at any point; everything autosaves. Returning through the same link later picks up exactly where they left off.
  5. If they lose the email, a chase reminder — automatic, or one you trigger with send_chase — resends the link (see Chase Engine).

No password, no email-confirmation step, no "forgot password" flow to maintain on either side. The identity check already happened by the time BriefGate sent the invitation: you supplied the client's address when you called define_intake.

What that means technically

An account substitutes a password (something the client knows) for identity. Here, the substitute is possession of a link — a bearer credential, the same category as a session cookie or an API key, mailed instead of typed. Three choices make that safe enough to build on:

The token is never stored as plaintext. The magic token is derived server-side from the intake and a server secret (an HMAC, not a random value you'd have to keep a copy of); the database only ever sees its hash, the same treatment API keys and portal session tokens get. A stolen database dump hands out hashes, not working links.

The link keeps working for its whole validity window, not just once. It's tempting to assume a link should die the moment it's clicked. This one doesn't: it stays valid until it hits its TTL — an account setting, portal_link_ttl_days, 30 days by default — and clicking it again, the next morning, from a different device, keeps working. That matches the job: a client fills in half an intake, gets distracted, comes back days later on their laptop instead of their phone. A single-use link would strand them with nothing to do but email you for a new one; a link that survives repeated use means "click it again" just works, on any device, until it genuinely expires.

Every send extends the same link's life, instead of replacing it. An intake has one stable link for its whole life — the invitation, every scheduled reminder, and a manual send_chase all carry the identical URL. What changes on each send is the clock: the TTL counts down from the most recent email sent for that intake, so an active chase sequence keeps pushing the expiry out rather than the link quietly going stale mid-cadence. An old email sitting in an inbox for months still points at a working link, right up until portal_link_ttl_days passes since the last thing BriefGate sent about that intake.

The resulting session is scoped tightly. Redeeming a link gets the client an httpOnly cookie, restricted to /portal paths so it's never sent to the API, valid for 14 days. It authorizes exactly one intake — not the client's other projects, not anyone else's portal. There's no broader "client account" behind it that a leaked cookie would expose.

The alternatives, and why they lost

An account with a password. The default reflex for "give someone access to something," and wrong for this job. The person filling in a BriefGate intake isn't your user — it's your client's staff, a subcontractor, someone's assistant, filling in one form for one project they may never touch again. Asking them to invent a password for a site they'll visit once creates exactly the friction this product exists to remove: a client who bounces off a signup form doesn't send the logo files either. An account is also an ongoing liability — a password to secure, reset, and eventually let the client walk away from — for a relationship with a natural end date.

A link that dies on first click. Often the right call for something like a password-reset email, where the goal is a tight, one-shot proof of inbox ownership. Wrong shape here, because the job isn't proving identity once — it's letting someone return across days and devices without an account to remember who they are between visits. A single-use link routes every return trip through you: dead link, client emails you, you trigger another send_chase. That's a support burden invented by the security model, not by anything the client did.

Email attachments and reply-by-email. Some competitors let a client reply with a ZIP attached, skipping the portal entirely. It looks like less friction, but it throws away what the portal buys you: no antivirus scan, no content-type sniffing, no HEIC conversion, no per-item validation, no encryption for sensitive values. Every protection in Security — the malware scan, the sealed-box encryption on secret items, the signed download URLs — exists because the file or value passed through the portal's pipeline. An emailed attachment bypasses all of it by construction, which is why BriefGate never attaches client-brief files to outgoing mail either (see Client brief).

The honest cost

None of this is free, and it's worth being direct about the trade.

Anyone holding the link holds the access. There's no second factor behind a portal link the way there is behind a password plus TOTP. If a client forwards the invitation to a subcontractor, that person can now submit — and see — everything already on the intake. That's often exactly what you want (see More than one person at the client for the supported way to add a second recipient deliberately), but it also means a link pasted into the wrong Slack channel or a shared inbox is a real exposure. BriefGate has no way to tell whether the person who redeemed a link is the client you emailed or someone they forwarded it to.

A forwarded email forwards the access, for a while. Because the link keeps working until its TTL rather than dying after one use, an old invitation sitting in a shared mailbox stays a live credential for up to portal_link_ttl_days — 30 days by default, and every reminder that goes out resets that clock, so an intake under active chase keeps the old forward alive too. Set retention.mode: "on_delivery" (see Security → Retention and deletion) where that window is uncomfortable, so the underlying data is gone well before the link's TTL would matter.

There's no client-side recovery flow. If a client loses the email, the fix is on your side: trigger a resend. Simpler than a password reset, but the client has no self-service way back in — they have to reach you, or wait for the next scheduled reminder.

Weighed against an account nobody would want to create for a form they'll fill in once, this is the trade BriefGate makes on purpose: treat the link as the credential, protect it the way any bearer token should be protected — hashed at rest, scoped narrowly, its lifetime capped and renewed only by BriefGate's own sends — and accept that whoever holds it has the access. The same trade every magic-link product makes, made explicit instead of glossed over.