Security and privacy

Last updated May 29, 2026 · Operators

This page is for merchants who want to understand how the portal is built to protect their data and their customers’. It’s intentionally non-technical at the top and gets more specific further down.

At a glance

  • Email + password sign-in, operator-provisioned. You issue your customer a one-time temporary password from their customer record; they sign in and immediately set their own. Self-serve “forgot password” handles recovery without you in the loop. No public self-signup.
  • Passwords are bcrypt-hashed. Work factor 12 — the same hasher you use for operator logins. We never see, log, or store plaintext.
  • Lockout after 5 wrong passwords. Per-account 15-minute cooldown protects against credential stuffing while still letting your customer retry the same hour.
  • No card data on our servers. Card numbers go straight from your customer’s browser to Stripe. We never see them. (This keeps you in PCI SAQ-A scope — the lowest-friction compliance category.)
  • One company’s data never crosses into another’s. Every database query filters by company. Every file is stored under a company-scoped key. Every browser cookie is scoped to its own auth context. Passwords are per-company — the same email under two different merchants has two independent passwords.
  • Webhooks are signed and idempotent. We can’t be tricked by spoofed Stripe events, and a re-delivered event is safely processed exactly once.
  • The portal is not search-indexable. Every page emits noindex,nofollow.

Password sign-in

Provisioning

There’s no public registration. You provision portal access from the customer record:

  1. Flip Portal access enabled to on.
  2. Click Send Temporary Password.

The system generates a random temp password, bcrypt-hashes it (work factor 12), stores the hash, sets a “must change” flag, revokes any existing portal sessions for that customer, and emails the plaintext to the customer’s address on file. The plaintext crosses one method boundary and is then discarded — it’s never logged or persisted.

First sign-in

The customer signs in with their email + the temp password and is forced into a change-password screen before they reach the dashboard. Their new password replaces the hash, the must-change flag is cleared, and the temp password is no longer valid.

Returning sign-in

Email + password against the stored bcrypt hash. On success we issue a PortalCookie session (see “Long-lived browser session” below). On any failure — wrong password, no such email, portal disabled, no password ever set, account locked out — the response is identical: same body, same status, same timing class. We don’t tell attackers which emails are real (no email enumeration).

Forgot password (self-serve)

The customer clicks Forgot password? on the sign-in page and enters their email. The response is always the same generic confirmation regardless of whether the email matched. If it does match an enabled customer, we generate a 256-bit random token, store its bcrypt hash with PURPOSE='password_reset' and a 15-minute expiry in the existing token table (same shape that previously held magic-link tokens), and email the link.

The reset link is single-use and expires after 15 minutes. Clicking lets the customer set a new password; we then revoke all existing sessions for that customer and sign the new session in.

Brute-force protection

We track per-(CompanyId, Email) failed sign-in counts in portal_signin_attempt. After 5 consecutive failures the account is locked for 15 minutes. A successful sign-in resets the counter. The locked-out response is the same generic “Email or password is incorrect” — we don’t reveal which accounts are under attack.

Rate limits

Endpoint Limit
Sign-in POST 10 / minute per IP
Forgot-password POST 3 / minute per IP + email
Reset-link consume 10 / minute per IP

Past the limit, the endpoint returns 429.

Long-lived browser session

Once a customer logs in, they get a session cookie:

  • HttpOnly, Secure, SameSite=Lax
  • Path-scoped to /portal/ (your operator session cookie does the opposite — it’s not sent on /portal/... requests)
  • 90-day sliding expiration

If you flip a customer’s Portal access enabled from on to off, the next request from their browser signs them out and revokes the session.

If you change a customer’s email, existing sessions are revoked.

Two separate auth schemes

The operator app (you and your team) uses one cookie. The portal (your customers) uses a separate one. Neither cookie is sent on the other’s routes. This means even a compromised operator session cannot impersonate a customer on the portal, and vice versa.

Tenant isolation

Every portal query filters by both CompanyId (the merchant) and CustomerId (the customer). Both values come from the signed session cookie — never from a URL or form field. Trying /portal/{slug}/invoice/{otherCustomerInvoiceId} returns 404, and the attempt is logged.

Input sanitization (XSS)

Login-page copy, taglines, and the fee disclosure text are all sanitized server-side with HtmlSanitizer before they’re saved. <script> tags, event handlers, and javascript: URLs are stripped. Even if you (or a malicious admin) tried to inject XSS into your own portal copy, it can’t reach a customer’s browser.

File upload safety

Two things to know:

  1. Customers cannot upload anything. No upload routes exist in the portal controller. Any crafted POST returns 404 or 405.
  2. Logo and favicon uploads (admin side) are content-sniffed. We look at the file bytes, not the extension. SVG is always rejected — even if renamed to .png — because SVG can contain JavaScript. Size caps are enforced before the file hits storage.

Idempotent webhooks

Every Stripe webhook event we receive is stored by its unique event ID. If the same event is re-delivered (network blip, retry), we process it exactly once. Payment rows are never double-written; invoices are never double-marked-paid.

We also verify Stripe’s webhook signature on every incoming event. A bad signature returns 400 and writes nothing.

Surcharge legality warning

If you turn on fee passthrough (Settings → Portal → Customer pays the processing fee), we display a warning banner about US state restrictions:

Surcharging on credit cards is restricted or prohibited in some US states (currently CA, CO, CT, FL, KS, MA, ME, NY, OK, TX — subject to change). You are responsible for confirming it’s legal where your customers live.

This list changes — confirm with your accountant or attorney. The toggle is not state-gated; you choose. Debit cards have stricter rules than credit cards; by default we suppress the surcharge on debit even when the toggle is on.

What PII the portal stores

For each customer who uses the portal:

  • Email address (you already had this in the admin app)
  • bcrypt(12) hash of their portal password — never the plaintext
  • PasswordUpdatedUtc — last time the password was rotated
  • PortalInviteSentUtc — last time you clicked “Send Temporary Password” for this customer
  • Last-login timestamp
  • Saved payment methods — but only metadata (brand, last 4 digits, expiration; bank name + last 4 for ACH). Full numbers stay with Stripe.
  • Login session metadata (issued, last used, expires, IP, user agent)
  • Sign-in attempt metadata (failed count, locked-until timestamp) — used by the lockout flow
  • Password-reset token metadata (issued, expired/consumed timestamps, IP — never the plaintext token)

We do not store SSNs, government IDs, or full bank account numbers.

How to revoke access if something looks wrong

  • Suspicious activity on one customer: flip Portal access enabled off on their customer record. Existing sessions are revoked at the next request.
  • Suspicious activity company-wide: flip the master Enable customer portal toggle off. Nobody can log in until you flip it back.
  • A Stripe account issue: disconnect Stripe under Settings → Stripe Connect. The portal still works; only the Pay button stops working.

Reporting a security issue

If you find a vulnerability in the portal, email {{SUPPORT_EMAIL}} with details. We take responsible disclosure seriously and will follow up promptly.

Put it to work in your own account

14-day free trial · No credit card required