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 cookieguidely_sessionishttpOnly,SameSite=Lax,Securein production, 30-day expiry, revocable from Account settings. - Mutating management requests must carry a matching
OriginorSec-Fetch-Site: same-origin(CSRF). - GitHub and Google login: OAuth 2 with a signed, 10-minute
statethat also carries a nonce matched against a short-livedHttpOnlycookie on the browser that started the flow (no login CSRF), and a same-sitenextpath only. The callback exchanges the code server-side and reads the verified email (GitHub: primary verified email and organizations viaread:org; Google: OpenID userinfo,email_verifiedmust 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 whileGITHUB_ALLOWED_ORGSis 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).requireWorkspaceRolerefuses 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
hdclaim (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
Actorand 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 gets404. - Roles: OWNER > ADMIN > EDITOR > VIEWER. Each role grants default permissions (
src/lib/permissions.ts);WorkspaceMembership.permissionGrants/permissionRevokesadjust 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 changeeditorsCanPublishProduction, so a granted permission can't widen anyone's access further. Production publishing requiresguides.publishProductionunless 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 inPLATFORM_ADMIN_EMAILS) unlocks/adminand/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 gets404, so the surface is not discoverable.PLATFORM_ADMIN_EMAILSis 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 itssrcis exactly the canonical YouTube (youtube-nocookie.com), Vimeo, Loom or Wistia player URL thattoEmbedUrlin@guidelytech/sharedproduces; a<video>only with an absolute http(s)src. A PDF is ordinary markup (div.gd-pdfwith ana.gd-filelink and its pages asimgs, seemedia.ts): renderers draw a viewer only for agd-filelink 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 undermedia/. 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 againstContent-Lengthand 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 onlymedia/*from the private bucket. Responses carry the sniffed type,nosniff,Content-Security-Policy: default-src 'none'; sandbox,Content-Disposition: inlineandCross-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.:extroute 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 andnoindex; only/embed/*may be framed by other sites (frame-ancestors *, noX-Frame-Options), every other route keepsX-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 matchingpassword|secret|token|credit|card|cvv|ssn|ibanare dropped client- and server-side. - URL collection can be disabled per project (
collectUrls);maskSelectorslets you list DOM selectors the authoring picker must never capture text from. - Consent: initialise the SDK with
consent: "pending"to buffer analytics untilgrantConsent();analytics: falsedisables 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). WithcollectUrlsoff 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:
localStoragekeysguidely:anon,guidely:config:<key>,guidely:state:<key>,guidely:queue:<key>,guidely:seen,guidely:conversations:<key>(messenger conversation tokens, cleared byreset());sessionStoragekeyguidely: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
Originheader 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-Forhop (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_URLis 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_…, scopesourcemaps:write). Keys are shown once, stored as SHA-256 hashes, revocable, and rate limited. Publicpk_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-Lengthcan'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.