Security & privacy

Authentication & sessions

  • Passwords are hashed with bcrypt (12 rounds). Login is rate limited per IP; failed and successful logins take the same time.
  • Sessions are random 256-bit tokens stored hashed (SHA-256) in Session; the cookie guidely_session is httpOnly, SameSite=Lax, Secure in production, 30-day expiry, revocable from Account settings.
  • Mutating management requests must carry a matching Origin or Sec-Fetch-Site: same-origin (CSRF).
  • GitHub and Google login: OAuth 2 with a signed, 10-minute state that also carries a nonce matched against a short-lived HttpOnly cookie on the browser that started the flow (no login CSRF), and a same-site next path only. The callback exchanges the code server-side and reads the verified email (GitHub: primary verified email and organizations via read:org; Google: OpenID userinfo, email_verified must be true). Existing accounts are linked by verified email; linking to a password account whose email was never verified removes that password and its sessions, so whoever registered the address first keeps no way in.
  • Who may sign in: a session is created only when the person can open something: a workspace they belong to that allows the method, a workspace auto-join rule they match, a pending invitation (for their email, into a workspace allowing the method), platform admin, or open signup (AUTH_SIGNUP_ENABLED; for GitHub only while GITHUB_ALLOWED_ORGS is empty). Password logins are checked the same way after the password is verified, so a wrong password and a refused login are indistinguishable to someone without the password.
  • Per-workspace sign-in methods: each session records how it signed in (Session.authMethod) and each workspace lists the methods it allows (Workspace.allowedAuthMethods). requireWorkspaceRole refuses a member whose session used another method (403, details.reason = "auth_method"), and the dashboard sends them to the workspace's sign-in page. Sessions created before methods were tracked only open unrestricted workspaces. Admins cannot save a policy that would lock out their own session.
  • Auto-join rules: GitHub organizations match the user's org memberships at sign-in; Google domains match the hd claim (Google Workspace accounts, controlled by that domain's admin), never the email's domain. A workspace admin can only add an org or domain they belong to themselves (as of their last GitHub / Google sign-in); platform admins can add any.
  • Workspace sign-in links: GET /auth/providers?workspace=<slug> is public so /login?workspace=<slug> can render before sign-in; it reveals the workspace's name and allowed methods for a known slug, nothing else.
  • Workspace-managed logins: admins can create password accounts that belong to their workspace (User.managedByWorkspaceId). Only those accounts can have their password reset by a workspace admin (which revokes their sessions), and they cannot be invited to, added to, auto-joined into or create any other workspace, so a workspace admin can never take over an account that reaches someone else's workspace. Removing such a member, or deleting the workspace, deletes the account. Creating a login for an email that already has an account is refused (invite them instead).
  • The public SDK API (/api/v1/sdk/*) and the embed script stay reachable without a session by design, because customer websites call them; they expose only published configuration for the key's environment.

Authorization

  • Every service function takes an Actor and resolves the workspace from the addressed resource (requireWorkspaceRole, requireProject, requireGuide). Client-supplied workspace/project IDs are never trusted as proof of access; a non-member gets 404.
  • Roles: OWNER > ADMIN > EDITOR > VIEWER. Each role grants default permissions (src/lib/permissions.ts); WorkspaceMembership.permissionGrants / permissionRevokes adjust them per member and checks pass a permission instead of a role. Owners always hold every permission. Member management, sign-in policy, the audit log and workspace deletion are role-only, admins can't edit their own permissions, only owners edit an admin's, and only admins may change editorsCanPublishProduction, so a granted permission can't widen anyone's access further. Production publishing requires guides.publishProduction unless the project enables it for editors.
  • Public SDK keys only expose published configuration for one environment, scoped by allowed origins. They cannot read drafts, analytics, other environments, or any management data.
  • Authoring sessions use random tokens (hashed at rest), expire after 2 hours, are bound to the creating user, and only expose the draft of the guide being authored.

Platform admins

  • User.isPlatformAdmin (or a verified email listed in PLATFORM_ADMIN_EMAILS) unlocks /admin and /api/v1/admin/*, which manage every workspace without membership: list, create for an existing owner, rename, delete, and add/remove members or change roles. Everyone else gets 404, so the surface is not discoverable.
  • PLATFORM_ADMIN_EMAILS is ignored for unverified emails, so self-service password signup cannot claim a configured admin address.
  • Admin access does not bypass workspace authorization elsewhere: to open a workspace's projects in the dashboard an admin adds themselves as a member, which is audited (admin.member.add).
  • The "at least one owner" rule still applies. Admins cannot revoke their own admin flag; config-granted admins can only be removed from configuration.

Content safety

  • All author HTML is sanitized server-side with DOMPurify (allow-list of formatting tags; no scripts, styles, forms, event handlers, javascript:/data: URLs) and again in the runtime before injection. Guide content cannot execute scripts.
  • Media in HTML: an <iframe> survives only when its src is exactly the canonical YouTube (youtube-nocookie.com), Vimeo, Loom or Wistia player URL that toEmbedUrl in @guidelytech/shared produces; a <video> only with an absolute http(s) src. A PDF is ordinary markup (div.gd-pdf with an a.gd-file link and its pages as imgs, see media.ts): renderers draw a viewer only for a gd-file link with an absolute http(s) href, and never frame the PDF itself. Both are rebuilt with fixed attributes (iframes: sandbox="allow-scripts allow-same-origin allow-presentation allow-popups allow-popups-to-escape-sandbox", referrerpolicy, allow="fullscreen; picture-in-picture; encrypted-media"; videos: controls, no autoplay), never the author's. The server, the web runtime and the mobile SDKs apply the same rules independently.
  • Article media uploads (POST /projects/:id/media, editor role, same-origin check, 320 per user per 10 minutes so a PDF and its rendered pages fit, 5 GB per project) stream to the private bucket under media/. The stored type comes from the file's leading bytes (PNG, JPEG, GIF, WebP, MP4, WebM, PDF only; SVG and HTML are refused), never from the client, and every byte is counted against Content-Length and the per-type limit (10 MB images, 200 MB video, 50 MB PDF). Files are public (random 21-character ids; widgets on customer sites load them without credentials). In production they are served by CloudFront from a separate host (MEDIA_PUBLIC_URL) that never receives dashboard cookies; CloudFront may read only media/* from the private bucket. Responses carry the sniffed type, nosniff, Content-Security-Policy: default-src 'none'; sandbox, Content-Disposition: inline and Cross-Origin-Resource-Policy: cross-origin, so a file opened directly can't run script (browsers' built-in PDF viewers still display PDFs under it; PDF pages shown in articles are images the dashboard rendered with pdf.js at upload). The app's own /api/v1/media/:projectId/:id.:ext route sends the same headers and keeps serving links created before the CDN (and is the default without one).
  • How-to recordings use a capture token like authoring (random, stored hashed, 2-hour TTL, bound to one how-to; pick-mode and capture tokens are not interchangeable). The SDK drops typed values from sensitive fields and pixelates sensitive fields, masked elements and email/card/SSN/phone-like text in the screenshot before uploading it; uploads are sniffed as images and limited to 8 MB, 600 steps per recording per 10 minutes and 200 steps per how-to. Manual redaction re-encodes the image on the server with the regions pixelated and deletes the original object. Shared how-tos (/share/:token, /embed/:token, 18-byte random tokens) are public and noindex; only /embed/* may be framed by other sites (frame-ancestors *, no X-Frame-Options), every other route keeps X-Frame-Options: SAMEORIGIN.
  • Resource center audience targeting only changes which articles the widget lists. The published config and article bodies are the same for every visitor and end-user identities are self-asserted, so targeting is not access control.
  • Button URLs must be http(s) or relative.
  • The runtime renders inside a closed Shadow DOM with all: initial, so host CSS cannot leak in and Guidely CSS cannot leak out.

Data minimisation & privacy

  • The SDK only sends the traits your application passes to identify(). Property keys matching password|secret|token|credit|card|cvv|ssn|iban are dropped client- and server-side.
  • URL collection can be disabled per project (collectUrls); maskSelectors lets you list DOM selectors the authoring picker must never capture text from.
  • Consent: initialise the SDK with consent: "pending" to buffer analytics until grantConsent(); analytics: false disables event collection entirely while keeping guides working.
  • Product analytics: autocaptured clicks never include input values, and their text is dropped for masked elements; session recordings always mask inputs, mask text under maskSelectors/[data-guidely-mask] (or all text), and leave out [data-guidely-block] elements entirely. Replay is off until an admin enables it, sampling is per session, and recordings have their own retention (default 30 days). Network requests in recordings carry metadata only by default (sensitive query values redacted, Guidely's own requests excluded); headers and bodies need a separate opt-in and are still redacted (auth/cookie headers, sensitive JSON fields, 10 KB text/JSON only). Recordings are stored in a private, encrypted S3 bucket and only ever served through the authenticated management API (no public or presigned URLs). With collectUrls off the server stores no paths, titles, referrers or link targets. Details: product-analytics.md.
  • Retention: EVENT_RETENTION_DAYS (default 365) with a per-project override; the worker deletes expired events nightly.
  • Deletion: Users → user → "Delete user data" removes the profile, events, sessions, recordings, progress and responses (DELETE /projects/:id/users/:userId).
  • Storage used by the SDK: localStorage keys guidely:anon, guidely:config:<key>, guidely:state:<key>, guidely:queue:<key>, guidely:seen, guidely:conversations:<key> (messenger conversation tokens, cleared by reset()); sessionStorage key guidely:session, guidely:authoring. No cookies are set by the SDK.

Scout and AI

  • What leaves Guidely. When Scout is on and an Anthropic key is configured (the project's own or ANTHROPIC_API_KEY), the conversation text, the published article catalog (titles and summaries), the bodies of articles Scout reads, published FAQs, learned answers and the names of live guides are sent to the Anthropic API to write replies. With learning on, the text of conversations a teammate resolved is sent once more to write a learned answer. Uploaded import files are sent to draft articles. End-user traits, events and URLs are not sent.
  • Per-project API keys. Admins can give a project its own Anthropic key. It is checked against the Anthropic API before saving, stored AES-256-GCM encrypted with a key derived from SESSION_SECRET, never returned by the API (only its last four characters), and left out of audit logs. Scout, the Playground and knowledge imports for that project use it instead of the server key.
  • Untrusted output. Scout's replies are rendered as text by the widget (render-safe.ts): paragraphs, bold, lists, code and http(s) links only — never images or HTML, so a manipulated reply cannot load remote content or run script. Article cards and "Show me" buttons come from structured, server-validated data (only real published articles and live guides).
  • Prompt injection. User messages and article text are treated as data; Scout's tools are read-only lookups plus handoff, so it cannot change data or reach other users' conversations.
  • Learned answers. Only conversations a teammate replied in and closed are studied, and the learned answer may only use facts the teammate stated (never the customer's or Scout's), written without names, accounts or other identifying details. Customers can't add knowledge by chatting. They are shown to other end users through Scout's replies, so projects that want a human check can turn on Review learned answers first; edits, approvals and deletions are audit-logged, and articles win over learned answers when they disagree. Learned answers are kept when retention deletes the source conversation.
  • Conversation access. Each conversation has a random token held by the browser that started it (only its SHA-256 is stored). Conversations are never listed by anonymous id or user id, because identify() is unauthenticated. The Inbox labels user identity as unverified.
  • Cost and abuse limits are counted in Postgres so every web task shares them: replies per project per hour and month (Scout settings), 25 Scout replies per conversation, 20 new conversations per IP per hour, one reply generating at a time per conversation, 4,000-character messages and 32 KB request bodies. Allowed origins do not stop scripted access (requests without an Origin header are accepted), which is why these limits are server-side.
  • Retention. Conversations that are closed or not waiting on the team follow the event retention window. Uploaded files and Intercom access tokens (stored encrypted like project API keys) are deleted from the database as soon as an import finishes.

Abuse protection

  • Client IPs come from the last X-Forwarded-For hop (the one the load balancer appends); earlier, client-supplied hops are ignored.
  • Rate limits: 600 req/min per public key + IP on SDK routes, 600 req/min per user on management routes, 30 login attempts / 15 min per IP, 20 signups / hour per IP. Redis-backed when REDIS_URL is set.
  • Event batches are capped at 50 events, 8 KB of properties per event, future timestamps are clamped, and duplicates (eventId) are ignored.
  • Source maps are uploaded with secret project keys (gly_sk_…, scope sourcemaps:write). Keys are shown once, stored as SHA-256 hashes, revocable, and rate limited. Public pk_ keys can't upload. Maps are kept private in S3 and never fetched from customer sites.
  • **Match your website** (Branding) is the only feature that fetches a URL a user types. It allows

    http(s) on ports 80/443 only, resolves the host itself and refuses private, loopback, link-local, CGNAT and other non-public addresses (including IPv4-mapped IPv6), connects to exactly the address it checked so DNS rebinding can't swap it, re-checks every redirect, and caps time (10 s) and size (3 MB page, 2 MB per stylesheet, after decompression). It needs the Admin role and is rate limited.

  • Error alerts post only to https://hooks.slack.com/services/… webhook URLs (no arbitrary outbound requests). Integration secrets such as webhook URLs are masked in every API response. Alerts have a per-issue cooldown, and ignored issues never alert.
  • Replay uploads are capped at 2 MB compressed and 16 MB decompressed (decompression stops at the cap, so a gzip bomb cannot exhaust memory), counted as the body streams in, so a request without Content-Length can't exceed them; duplicate chunks are ignored. A recording only accepts chunks from the visitor it belongs to, so a known session id can't be used to inject into or take over someone else's recording. The SDK throttles exceptions to 5 identical per page load and 100 per session.

Tests

apps/web/src/server/__tests__/*.integration.test.ts cover tenant isolation, role enforcement, immutable versions, sanitisation, origin restrictions, idempotent ingestion and data deletion.