Fullstory Privacy Strategy
Overview
This guide provides strategic guidance for implementing privacy-conscious Fullstory semantic decoration. It helps developers decide:
- What data to send to Fullstory
- What data to never send
- What data to hash/encrypt before sending
- When to use joinable keys instead of raw data
- How to balance analytics value with privacy protection
Remember: Fullstory is a tool for understanding user experience, NOT a database for storing customer data. Send only what's needed for analysis.
Fullstory's Privacy Architecture
First-Party Cookies (Not Third-Party)
Fullstory uses first-party cookies set on YOUR domain, which provides inherent privacy benefits:
| Privacy Aspect | Fullstory Approach |
|----------------|-------------------|
| Cookie Domain | Set on YOUR domain (e.g., yoursite.com), not Fullstory's |
| Cross-Site Tracking | ❌ Impossible - each site has its own isolated fs_uid cookie |
| Data Isolation | Your user data is completely isolated from other Fullstory customers |
| Browser Compatibility | ✅ First-party cookies aren't blocked by browsers or ad-blockers |
| User Control | Users can clear cookies to reset their identity on your site |
Key Privacy Guarantee: A user's identity CANNOT be connected across different sites using Fullstory. If the same person visits Site A and Site B (both using Fullstory), they have separate, unlinked identities on each site.
Cookie Transparency
| Cookie | Duration | Purpose | User Impact |
|--------|----------|---------|-------------|
| fs_uid | 1 year | Links sessions from same browser | Can clear to "start fresh" |
| fs_cid | 1 year | Stores consent state | Remembers consent choice |
| fs_lua | 30 min | Last activity timestamp | Session timeout management |
Reference: Why Fullstory uses First-Party Cookies
Private by Default Mode
Fullstory offers a Private by Default mode—a privacy-first capture approach that inverts the default behavior:
| Mode | Default Behavior | Best For | |------|------------------|----------| | Standard | Capture everything, add fs-mask/fs-exclude to protect | Low-sensitivity sites | | Private by Default | Mask everything, add fs-unmask to reveal | High-sensitivity applications |
How Private by Default Works:
- All text is masked by default - No text captured unless explicitly unmasked
- Zero accidental exposure - Impossible to accidentally capture sensitive data
- Selective unmasking - Add
.fs-unmaskto navigation, buttons, product names - Session replay shows wireframes - See user behavior without seeing data
Recommended for:
- ✅ Healthcare applications (HIPAA)
- ✅ Banking/financial services (PCI, GLBA)
- ✅ Multi-tenant SaaS (customer data protection)
- ✅ Enterprise applications
- ✅ Any application where "default open" is too risky
Enable via:
- New accounts: Select during onboarding wizard
- Existing accounts: Contact Fullstory Support
Reference: Fullstory Private by Default
Core Privacy Principles
1. Minimum Necessary Data
"Capture only what you need to understand the user experience."
| Data Category | Ask Yourself | Recommendation | |--------------|--------------|----------------| | User identifiers | Do I need to link sessions? | Use hashed/internal IDs | | Names | Do I need the actual name? | Mask or use initials | | Emails | Is email essential for lookup? | Hash or use user ID | | Addresses | Do I need full address? | Mask street, keep city/state | | Financial | Do I need actual amounts? | Use ranges ("$100-$500") | | Health | Is this needed at all? | Usually NO - exclude entirely |
2. Privacy by Design
Build privacy into your semantic decoration from the start, not as an afterthought:
┌───────────────────────────────────────────────────────────────┐
│ PHASE 1: DESIGN │
│ - Identify all data fields │
│ - Classify by sensitivity │
│ - Determine minimum needed for analysis │
├───────────────────────────────────────────────────────────────┤
│ PHASE 2: IMPLEMENT │
│ - Apply privacy controls (exclude/mask/unmask) │
│ - Hash or tokenize identifiers │
│ - Use joinable keys for linkage │
├───────────────────────────────────────────────────────────────┤
│ PHASE 3: VERIFY │
│ - Review session replays │
│ - Audit captured properties/events │
│ - Test edge cases (errors, modals, dynamic content) │
├───────────────────────────────────────────────────────────────┤
│ PHASE 4: MAINTAIN │
│ - Regular privacy audits │
│ - Update for new features/fields │
│ - Monitor for accidental exposure │
└───────────────────────────────────────────────────────────────┘
3. Data Classification Framework
| Classification | Examples | Fullstory Handling | |----------------|----------|-------------------| | Public | Product names, prices, UI text | Unmask | | Internal | Order IDs, session IDs | Send as-is or hash | | Confidential | Names, emails, addresses | Mask or hash | | Restricted | SSN, credit cards, passwords | Exclude always | | Regulated | Health data, financial data | Exclude + comply with regulations |
Decision Matrix: What Data to Send
User Identification
| Approach | When to Use | Example |
|----------|-------------|---------|
| Internal ID | Always preferred | setIdentity({ uid: "user_12345" }) |
| Hashed email | Need email linkage | setIdentity({ uid: sha256(email) }) |
| Raw email | Only if required for support | setIdentity({ uid: "user@example.com" }) |
| Joinable key | Link to external system | setIdentity({ uid: "cust_abc123" }) |
User Properties
| Data Type | Send Raw? | Hash? | Joinable Key? | Exclude? |
|-----------|-----------|-------|---------------|----------|
| Account ID | ✅ | | | |
| Account tier (Gold/Silver) | ✅ | | | |
| Company name | ⚠️ Consider | | ✅ company_id | |
| User's full name | | | ✅ user_id | ⚠️ Mask |
| Email | | ✅ | ✅ user_id | |
| Phone | | | | ❌ Don't send |
| Address | | | | ❌ Don't send |
| SSN/Tax ID | | | | ❌ Never |
| Account balance | ⚠️ Ranges | | | |
| Credit score | | | | ❌ Never |
Event Properties
| Data Type | Recommendation | Example |
|-----------|----------------|---------|
| Product ID | Send raw | { product_id: "SKU-123" } |
| Product name | Send raw | { product_name: "Wireless Headphones" } |
| Price | Send raw | { price: 199.99 } |
| Quantity | Send raw | { quantity: 2 } |
| Order ID | Send raw | { order_id: "ORD-789" } |
| Search query | ⚠️ Consider | May contain PII |
| Error message | ⚠️ Sanitize | May contain PII |
| User-generated content | ⚠️ Consider | Comments, reviews |
Joinable Keys Strategy
Instead of sending sensitive data to Fullstory, send an identifier that can be joined in your analytics warehouse:
The Pattern
Fullstory Your Data Warehouse
┌─────────────────┐ ┌─────────────────────┐
User Session: │ user_id: "u123" │ → │ user_id: "u123" │
│ session_events │ │ email: "john@co.com" │
│ page_views │ │ name: "John Smith" │
└─────────────────┘ │ ssn: "***-**-****" │
└─────────────────────┘
↓
JOIN ON user_id
↓
┌─────────────────────────────────────────────┐
│ Combined analytics with full PII context │
│ (in YOUR secure data warehouse) │
└─────────────────────────────────────────────┘
Implementation Example
// BAD: Sending PII directly to Fullstory
FS('setIdentity', {
uid: user.email, // PII!
displayName: user.fullName, // PII!
email: user.email // PII!
});
FS('setProperties', {
type: 'user',
properties: {
ssn: user.ssn, // NEVER!
phone: user.phone, // PII!
address: user.address // PII!
}
});
// GOOD: Using joinable keys
FS('setIdentity', {
uid: user.internalId // Not PII, just an ID
});
FS('setProperties', {
type: 'user',
properties: {
// Business context, not PII
account_tier: user.tier,
signup_date: user.createdAt,
plan_type: user.subscription.plan,
// Joinable keys for your warehouse
crm_id: user.salesforceId, // Join to Salesforce
support_id: user.zendeskId // Join to Zendesk
}
});
Joinable Keys for Common Systems
| System | Key to Send | Join In Warehouse |
|--------|-------------|-------------------|
| Salesforce | salesforce_account_id | Account, Contact objects |
| HubSpot | hubspot_contact_id | Contact properties |
| Zendesk | zendesk_user_id | User tickets, interactions |
| Stripe | stripe_customer_id | Payment, subscription data |
| Segment | segment_user_id | Full user profile |
| Internal DB | user_id, account_id | All internal data |
Hashing Strategy
When you need to identify users but can't use internal IDs:
When to Hash
| Scenario | Recommendation | |----------|----------------| | Need email for session linking | Hash email | | Multiple systems use email as key | Hash email | | Legal requires pseudonymization | Hash all PII | | Support needs to find user | Consider: do they need Fullstory or CRM? |
Hash Implementation
⚠️ SECURITY WARNING: Client-side hashing is NOT a security control - it's only a privacy measure. JavaScript code is inspectable, and unsalted hashes are reversible via rainbow tables. True security requires server-side processing. Use hashing only to prevent accidental PII exposure in Fullstory, not to protect against determined attackers.
// PREFERRED: Hash on your backend, pass to frontend
// This keeps the hashing logic and any salt server-side
const userFromAPI = await fetchUser(); // { id, hashedEmail: "5e884..." }
FS('setIdentity', {
uid: userFromAPI.hashedEmail
});
// ---
// ALTERNATIVE: Client-side hashing (less secure, but better than raw PII)
async function hashForFullstory(value) {
const encoder = new TextEncoder();
const data = encoder.encode(value.toLowerCase().trim());
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
const hashArray = Array.from(new Uint8Array(hashBuffer));
return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
}
const hashedEmail = await hashForFullstory(user.email);
FS('setIdentity', {
uid: hashedEmail // e.g., "5e884898da28047d..."
});
Important Considerations
- Server-side hashing is preferred: Hash on backend, send hash to client
- Consistent hashing: Always lowercase, trim whitespace before hashing
- Salt optional for Fullstory: Unsalted hashes allow you to join later
- Document mapping: Keep record of hash → original for support lookups
- Not a security measure: Hashing prevents accidental exposure, not malicious extraction
Regulatory Compliance Guide
GDPR (Europe)
| Requirement | Fullstory Approach |
|-------------|-------------------|
| Consent before processing | Use FS('setIdentity', { consent: true/false }) |
| Right to be forgotten | Use Fullstory's deletion API |
| Data minimization | Use joinable keys, not raw PII |
| Purpose limitation | Only capture UX-relevant data |
| Pseudonymization | Hash identifiers |
// GDPR-compliant identification
function identifyUserGDPR(user, hasConsent) {
FS('setIdentity', {
uid: hashEmail(user.email), // Pseudonymized
consent: hasConsent // Explicit consent
});
if (hasConsent) {
FS('setProperties', {
type: 'user',
properties: {
// Only essential, non-PII data
account_tier: user.tier,
country: user.country, // May be needed for analysis
language: user.language
}
});
}
}
HIPAA (Healthcare - USA)
| Requirement | Fullstory Approach | |-------------|-------------------| | PHI protection | Exclude ALL health-related content | | Minimum necessary | Capture only UX metrics | | Audit trail | Use Fullstory's audit logs | | BAA required | Ensure signed with Fullstory |
// HIPAA-compliant approach
// DO NOT capture:
// - Diagnoses, conditions
// - Medications
// - Treatment plans
// - Provider names
// - Insurance info
// - Any PHI
FS('setIdentity', {
uid: patient.internalId // Not PHI
});
FS('setProperties', {
type: 'user',
properties: {
// OK: Non-PHI operational data
portal_type: 'patient',
preferred_language: 'en',
login_method: 'SSO',
// NOT OK - do not include:
// diagnosis: patient.conditions ❌
// provider: patient.doctorName ❌
}
});
PCI DSS (Payment Cards)
| Data Type | Allowed in Fullstory? | |-----------|----------------------| | Card number | ❌ Never (auto-excluded) | | CVV/CVC | ❌ Never (auto-excluded) | | Expiry date | ❌ Never (exclude) | | Cardholder name | ❌ No (exclude) | | Last 4 digits | ⚠️ Maybe (for reference) | | Card brand | ✅ Yes (Visa, MC) | | Transaction ID | ✅ Yes | | Amount | ✅ Yes |
// PCI-compliant event tracking
FS('trackEvent', {
name: 'purchase_completed',
properties: {
// OK: Non-sensitive transaction data
order_id: order.id,
amount: order.total,
currency: order.currency,
card_brand: order.payment.cardBrand, // "Visa"
// NOT OK - do not include:
// card_number: order.payment.number ❌
// cvv: order.payment.cvv ❌
}
});
CCPA (California - USA)
| Requirement | Fullstory Approach | |-------------|-------------------| | Right to know | Document what Fullstory captures | | Right to delete | Use Fullstory's deletion API | | Right to opt-out | Use consent API | | Non-discrimination | Same experience regardless of opt-out |
Data Category Decision Trees
User Identification Decision Tree
Should I send this identifier to Fullstory?
│
├─ Is it an internal ID (no PII)?
│ └─ YES → Send as uid ✅
│
├─ Is it an email address?
│ ├─ Is email required for support lookup?
│ │ ├─ YES → Consider: Can support use internal system instead?
│ │ │ ├─ YES → Send hashed email or internal ID
│ │ │ └─ NO → Send email (document justification)
│ │ └─ NO → Send hashed email
│ └─ Can you link via internal ID?
│ └─ YES → Use internal ID instead
│
├─ Is it a phone number?
│ └─ → Don't send, use internal ID ❌
│
├─ Is it a username?
│ └─ → Could be PII, prefer internal ID
│
└─ Is it SSN/Tax ID/Government ID?
└─ → NEVER send ❌❌❌
Property Decision Tree
Should I send this property to Fullstory?
│
├─ Is it regulated data (PHI, financial details)?
│ └─ YES → Don't send ❌
│
├─ Does it contain PII?
│ ├─ Is PII necessary for analysis?
│ │ ├─ YES → Can you generalize? (age range vs DOB)
│ │ │ ├─ YES → Send generalized
│ │ │ └─ NO → Document justification, minimize
│ │ └─ NO → Don't send, use joinable key
│ └─ Can you use a joinable key instead?
│ └─ YES → Send key, join in warehouse
│
├─ Is it business context (tier, plan, industry)?
│ └─ YES → Send ✅
│
├─ Is it behavioral (feature flags, A/B variant)?
│ └─ YES → Send ✅
│
└─ Is it operational (version, platform)?
└─ YES → Send ✅
Common Patterns by Data Type
Names
// Options for handling names
// Option 1: Don't send at all (use joinable key)
FS('setIdentity', { uid: user.id });
// Look up name in your CRM/database
// Option 2: Send only first name (if needed for greetings analysis)
FS('setProperties', {
type: 'user',
properties: {
first_name_initial: user.firstName.charAt(0)
}
});
// Option 3: Mask in UI, don't send as property
// (handled by fs-mask class in HTML)
Email Addresses
// Options for handling emails
// Option 1: Internal ID (preferred)
FS('setIdentity', { uid: user.id });
// Option 2: Hashed email (for cross-system linking)
FS('setIdentity', { uid: sha256(user.email.toLowerCase()) });
// Option 3: Email domain only (for B2B analysis)
FS('setProperties', {
type: 'user',
properties: {
email_domain: user.email.split('@')[1] // "company.com"
}
});
Monetary Values
// Options for handling money
// Option 1: Send actual values (usually OK for transactions)
FS('trackEvent', {
name: 'purchase',
properties: {
amount: 149.99,
currency: 'USD'
}
});
// Option 2: Send ranges (for sensitive financial data)
function getAmountRange(amount) {
if (amount < 100) return '$0-$100';
if (amount < 500) return '$100-$500';
if (amount < 1000) return '$500-$1000';
return '$1000+';
}
FS('setProperties', {
type: 'user',
properties: {
account_balance_range: getAmountRange(user.balance)
}
});
// Option 3: Percentiles (for comparative analysis)
FS('setProperties', {
type: 'user',
properties: {
spending_percentile: user.spendingPercentile // 75
}
});
Dates
// Options for handling dates
// Option 1: Full date (usually OK for non-sensitive)
FS('setProperties', {
type: 'user',
properties: {
signup_date: user.createdAt.toISOString()
}
});
// Option 2: Relative time (for age-sensitive data)
function getAgeRange(birthDate) {
const age = calculateAge(birthDate);
if (age < 18) return 'under-18';
if (age < 25) return '18-24';
if (age < 35) return '25-34';
// etc.
}
FS('setProperties', {
type: 'user',
properties: {
age_range: getAgeRange(user.dateOfBirth)
}
});
// Option 3: Don't send DOB (link via joinable key)
Locations
// Options for handling location
// Option 1: Country/region only (usually OK)
FS('setProperties', {
type: 'user',
properties: {
country: user.address.country,
region: user.address.state
}
});
// Option 2: City (if needed for analysis)
FS('setProperties', {
type: 'user',
properties: {
metro_area: user.address.city // Consider privacy implications
}
});
// Option 3: Don't send address details
// Full addresses are PII - mask in UI, don't send as properties
Privacy Audit Checklist
Use this checklist before launch and periodically:
Session Replay Audit
- [ ] Watch 10 random replays from each user type
- [ ] Check all form fields for proper masking/exclusion
- [ ] Verify error messages don't expose PII
- [ ] Check modals and pop-ups for sensitive content
- [ ] Review any user-generated content areas
- [ ] Verify third-party widgets are properly handled
Properties Audit
- [ ] List all
setIdentitycalls and their values - [ ] List all
setPropertiescalls (user and page) - [ ] List all
trackEventcalls and their properties - [ ] For each: Is this data necessary? Is it minimized?
- [ ] Verify no regulated data in properties
- [ ] Check that joinable keys are used where appropriate
Console/Network Audit
- [ ] Check if console capture is enabled
- [ ] If yes, verify no PII in console logs
- [ ] Review network request capture settings
- [ ] Verify sensitive APIs are excluded
Compliance Verification
- [ ] Consent mechanism implemented (if required)
- [ ] Deletion API integrated (if required)
- [ ] Privacy policy updated
- [ ] Team trained on privacy requirements
KEY TAKEAWAYS FOR AGENT
When helping developers with privacy strategy:
- Default to privacy: When in doubt, don't send it
- Joinable keys are your friend: Send IDs, join PII in warehouse
- Hash when needed: For cross-system linking without raw PII
- Know the regulations: GDPR, HIPAA, PCI, CCPA have specific requirements
- Audit regularly: Privacy leaks happen with new features
Questions to Ask Developers
- "What regulations apply to your business?"
- "Can you use an internal ID instead of email?"
- "Is this data necessary for understanding UX?"
- "Can you join this data in your warehouse instead?"
- "Have you tested session replay for PII exposure?"
Common Mistakes to Watch For
- Sending email as uid when internal ID exists
- Including PII in event properties "for debugging"
- Not masking dynamically loaded content
- Forgetting about console log capture
- Assuming auto-detection catches everything
REFERENCE LINKS
Core Skills
- Privacy Controls - Technical implementation
- User Consent - Consent API
- Identify Users - User identification
Fullstory Documentation
- Privacy Overview: https://help.fullstory.com/hc/en-us/articles/360020623574
- Private by Default: https://help.fullstory.com/hc/en-us/articles/360044349073
- Data Deletion API: https://developer.fullstory.com/deletion
- GDPR Compliance: https://www.fullstory.com/legal/gdpr
This skill document provides strategic guidance for privacy-conscious Fullstory implementations. Always consult your legal and compliance teams for specific regulatory requirements.