Salesforce Hardening Guide
CRM platform security for MFA enforcement, Connected Apps, and Shield Event Monitoring
Salesforce Editions Covered: Enterprise, Unlimited, Performance (some controls require Shield add-on)
Overview
This guide provides comprehensive security hardening recommendations for Salesforce, organized by control category. Each recommendation includes both ClickOps (GUI-based) and Code (automation-based) implementation methods.
Intended Audience
- Security engineers configuring Salesforce security controls
- IT administrators managing Salesforce instances
- GRC professionals assessing Salesforce compliance
- Third-party risk managers evaluating integration security
How to Use This Guide
- L1 (Baseline): Essential controls for all organizations
- L2 (Hardened): Enhanced controls for security-sensitive environments
- L3 (Maximum Security): Strictest controls for regulated industries
Scope
This guide covers Salesforce-specific security configurations. For infrastructure hardening (AWS, Azure where Salesforce runs), refer to CIS Benchmarks.
Table of Contents
- Authentication & Access Controls
- Network Access Controls
- OAuth & Connected App Security
- Data Security
- Monitoring & Detection
- Third-Party Integration Security
1. Authentication & Access Controls
1.1 Enforce Multi-Factor Authentication (MFA) for All Users
Profile Level: L1 (Baseline) CIS Controls: 6.3, 6.5 NIST 800-53: IA-2(1), IA-2(2)
Description
Require all Salesforce users to use MFA for authentication, eliminating single-factor authentication vulnerabilities.
Rationale
- Attack Prevented: Credential stuffing, password spray, phished passwords
- Incident Example: Okta support breach (2023) - attackers used stolen credentials without MFA
ClickOps Implementation
- Navigate to: Setup → Identity → Multi-Factor Authentication
- Enable: “Require Multi-Factor Authentication (MFA) for all direct UI logins”
- Configure allowed authenticator types:
- ☑ Salesforce Authenticator (recommended)
- ☑ TOTP-based apps (Google Authenticator, Authy)
- ☐ SMS (NOT recommended - vulnerable to SIM swapping)
- Set enforcement date and communicate to users
- Verify in Login History: Setup → Security → Login History (check for MFA column)
Compliance Mappings
- SOC 2: CC6.1 (Logical Access)
- NIST 800-53: IA-2(1), IA-2(2)
- PCI DSS: 8.3
Code Pack: API Script
# Query active users who do not have MFA enabled
info "1.1 Querying active users without MFA..."
MFA_QUERY="SELECT Id, Username, Name, Profile.Name, IsActive, UserPreferencesDisableMFAPrompt FROM User WHERE IsActive = true"
USER_RESPONSE=$(sf_query "${MFA_QUERY}") || {
fail "1.1 Failed to query user MFA status"
increment_failed
summary
exit 0
}
TOTAL_USERS=$(echo "${USER_RESPONSE}" | jq '.totalSize // 0' 2>/dev/null)
info "1.1 Total active users: ${TOTAL_USERS}"
# Identify users with MFA prompt disabled (potential bypass)
MFA_DISABLED=$(echo "${USER_RESPONSE}" | jq '[.records[] | select(.UserPreferencesDisableMFAPrompt == true)]' 2>/dev/null || echo "[]")
DISABLED_COUNT=$(echo "${MFA_DISABLED}" | jq 'length' 2>/dev/null || echo "0")
if [ "${DISABLED_COUNT}" -gt 0 ]; then
warn "1.1 Found ${DISABLED_COUNT} user(s) with MFA prompt disabled:"
echo "${MFA_DISABLED}" | jq -r '.[] | " - \(.Username) (\(.Name), Profile: \(.Profile.Name // "unknown"))"' 2>/dev/null || true
else
pass "1.1 No users have MFA prompt disabled"
fi
# Verify org-wide session security settings require MFA
info "1.1 Checking org-wide session security level..."
SESSION_QUERY="SELECT Id, Name, SessionSecurityLevel FROM Profile WHERE Name IN ('System Administrator', 'Standard User')"
SESSION_RESPONSE=$(sf_query "${SESSION_QUERY}" 2>/dev/null || echo '{"records":[]}')
PROFILE_COUNT=$(echo "${SESSION_RESPONSE}" | jq '.totalSize // 0' 2>/dev/null)
if [ "${PROFILE_COUNT}" -gt 0 ]; then
info "1.1 Profile session security levels:"
echo "${SESSION_RESPONSE}" | jq -r '.records[] | " - \(.Name): Session Level = \(.SessionSecurityLevel // "Standard")"' 2>/dev/null || true
else
warn "1.1 Could not retrieve profile session settings"
fi
# Query permission sets with "Manage Multi-Factor Authentication" enabled
info "1.1 Checking permission sets for MFA management..."
PERM_QUERY="SELECT Id, Label, PermissionsManageMultiFactorInUi FROM PermissionSet WHERE PermissionsManageMultiFactorInUi = true"
PERM_RESPONSE=$(sf_query "${PERM_QUERY}" 2>/dev/null || echo '{"records":[]}')
PERM_COUNT=$(echo "${PERM_RESPONSE}" | jq '.totalSize // 0' 2>/dev/null)
if [ "${PERM_COUNT}" -gt 0 ]; then
pass "1.1 Found ${PERM_COUNT} permission set(s) with MFA management enabled"
echo "${PERM_RESPONSE}" | jq -r '.records[] | " - \(.Label)"' 2>/dev/null || true
else
warn "1.1 No permission sets with MFA management -- ensure MFA is enforced org-wide via Setup > Identity Verification"
fi
2. Network Access Controls
2.1 Restrict API Access via IP Allowlisting for Third-Party Integrations
Profile Level: L1 (Baseline) CIS Controls: 13.3, 13.6 NIST 800-53: AC-3, SC-7
Description
Configure Salesforce Network Access to restrict API calls from third-party integrations (like Gainsight, Drift, HubSpot) to their documented static egress IP addresses. This prevents compromised integrations from accessing your data from attacker-controlled infrastructure.
Rationale
Attack Prevented: Supply chain compromise via OAuth token theft
Real-World Incidents:
- Gainsight Breach (November 2025): Attackers exfiltrated data from 200+ Salesforce orgs using stolen OAuth tokens from compromised Gainsight infrastructure
- Salesloft/Drift Breach (August 2025): 700+ orgs compromised via stolen OAuth tokens
- Okta Survival: Okta was targeted but protected because they had IP allowlisting configured
Why This Works: Even if integration’s OAuth tokens are stolen, attackers cannot use them from infrastructure outside the integration’s documented IP ranges.
Code Pack: API Script
# Query all configured Login IP Ranges across profiles
info "2.1 Querying Login IP Ranges by profile..."
IP_QUERY="SELECT Id, ProfileId, Profile.Name, StartAddress, EndAddress, Description FROM LoginIpRange ORDER BY Profile.Name"
IP_RESPONSE=$(sf_query "${IP_QUERY}") || {
fail "2.1 Failed to query Login IP Ranges"
increment_failed
summary
exit 0
}
RANGE_COUNT=$(echo "${IP_RESPONSE}" | jq '.totalSize // 0' 2>/dev/null)
if [ "${RANGE_COUNT}" -gt 0 ]; then
pass "2.1 Found ${RANGE_COUNT} Login IP Range(s) configured"
echo "${IP_RESPONSE}" | jq -r '.records[] | " - Profile: \(.Profile.Name // "unknown") | \(.StartAddress) - \(.EndAddress) | \(.Description // "no description")"' 2>/dev/null || true
else
warn "2.1 No Login IP Ranges configured -- API access is unrestricted by IP"
warn "2.1 NIST SC-7: Network boundary protection requires IP-based access controls"
fi
# Query Trusted IP Ranges (org-wide network access)
info "2.1 Checking org-wide Trusted IP Ranges..."
TRUSTED_QUERY="SELECT Id, StartAddress, EndAddress, Description FROM SecuritySettings"
# Trusted IP ranges are in Network Access -- query via Setup API
NETWORK_RESPONSE=$(sf_tooling_query "SELECT Id, StartAddress, EndAddress, Description FROM NetworkAccess" 2>/dev/null || echo '{"records":[]}')
TRUSTED_COUNT=$(echo "${NETWORK_RESPONSE}" | jq '.totalSize // 0' 2>/dev/null)
if [ "${TRUSTED_COUNT}" -gt 0 ]; then
info "2.1 Found ${TRUSTED_COUNT} Trusted IP Range(s) (org-wide):"
echo "${NETWORK_RESPONSE}" | jq -r '.records[] | " - \(.StartAddress) - \(.EndAddress) | \(.Description // "no description")"' 2>/dev/null || true
else
warn "2.1 No org-wide Trusted IP Ranges configured"
fi
# Audit recent login IPs to identify unexpected sources
info "2.1 Auditing recent login source IPs (last 100 logins)..."
LOGIN_QUERY="SELECT Id, UserId, LoginTime, SourceIp, Status, Application, LoginType FROM LoginHistory ORDER BY LoginTime DESC LIMIT 100"
LOGIN_RESPONSE=$(sf_query "${LOGIN_QUERY}" 2>/dev/null || echo '{"records":[]}')
LOGIN_COUNT=$(echo "${LOGIN_RESPONSE}" | jq '.totalSize // 0' 2>/dev/null)
if [ "${LOGIN_COUNT}" -gt 0 ]; then
# Summarize unique source IPs
UNIQUE_IPS=$(echo "${LOGIN_RESPONSE}" | jq -r '[.records[].SourceIp] | unique | .[]' 2>/dev/null || true)
IP_COUNT=$(echo "${UNIQUE_IPS}" | grep -c . 2>/dev/null || echo "0")
info "2.1 Found ${IP_COUNT} unique source IP(s) in last ${LOGIN_COUNT} logins:"
echo "${UNIQUE_IPS}" | while read -r ip; do
COUNT=$(echo "${LOGIN_RESPONSE}" | jq "[.records[] | select(.SourceIp == \"${ip}\")] | length" 2>/dev/null || echo "?")
echo " - ${ip} (${COUNT} logins)"
done
# Flag failed logins from unexpected IPs
FAILED=$(echo "${LOGIN_RESPONSE}" | jq '[.records[] | select(.Status == "Failed")]' 2>/dev/null || echo "[]")
FAILED_COUNT=$(echo "${FAILED}" | jq 'length' 2>/dev/null || echo "0")
if [ "${FAILED_COUNT}" -gt 0 ]; then
warn "2.1 Found ${FAILED_COUNT} failed login(s) -- review source IPs:"
echo "${FAILED}" | jq -r '.[] | " - \(.SourceIp) at \(.LoginTime) via \(.Application // "unknown")"' 2>/dev/null || true
fi
else
warn "2.1 No login history available -- verify API token has login history read permissions"
fi
Code Pack: DB Query
-- Query for blocked Gainsight login attempts
SELECT Id, LoginTime, SourceIp, Status, Application
FROM LoginHistory
WHERE Application = 'Gainsight'
AND Status = 'Failed'
AND LoginTime = LAST_N_DAYS:7
Code Pack: Sigma Detection Rule
detection:
selection:
EventType: 'SetupAuditTrail'
Action|contains:
- 'insertedLoginIpRange'
- 'deletedLoginIpRange'
- 'updatedLoginIpRange'
- 'insertedNetworkAccess'
- 'deletedNetworkAccess'
condition: selection
fields:
- CreatedDate
- CreatedByContext
- Display
- Section
- DelegateUser
- ResponsibleNamespacePrefix
2.1.1 IP Allowlisting: Restricting Gainsight
Prerequisites
- Salesforce Enterprise Edition or higher
- Gainsight’s current static egress IP addresses
- System Administrator access
Gainsight IP Addresses
As of 2025-12-12, Gainsight uses these production egress IPs:
35.166.202.113/3252.35.87.209/3234.221.135.142/32
⚠️ Verify before implementing: Contact your Gainsight CSM or check Gainsight IP Documentation
ClickOps Implementation
Step 1: Navigate to Network Access
- Setup (gear icon) → Quick Find: “Network Access” → Network Access
Step 2: Add Gainsight IP Ranges For each IP address:
- Click “New” in Trusted IP Ranges section
- Enter:
- Start IP Address:
35.166.202.113 - End IP Address:
35.166.202.113 - Description:
Gainsight Production 1 - verified 2025-12-12
- Start IP Address:
- Click “Save”
- Repeat for remaining IPs (
52.35.87.209,34.221.135.142)
Step 3: Test Integration
- Trigger Gainsight manual sync
- Verify data flows correctly
- Check Login History for blocked attempts: Setup → Security → Login History
Time to Complete: ~10 minutes
Monitoring & Maintenance
Quarterly Review Checklist:
- Verify Gainsight IPs haven’t changed (contact CSM or check documentation)
- Update description fields with new verification date
- Review Event Monitoring logs for blocked attempts
- Test integration after any changes
Alert Configuration: If using Salesforce Shield Event Monitoring, see the DB Query in the Code Pack for section 2.1 above.
Operational Impact
- User Experience: None (users don’t interact with integration directly)
- Integration Functionality: Low risk if IPs verified with vendor
- Rollback: Easy - remove IP ranges from trusted list
Compliance Mappings
- SOC 2: CC6.6 (Boundary Protection)
- NIST 800-53: AC-3, SC-7, SC-7(5)
- ISO 27001: A.8.3 (Supplier relationships)
2.1.2 IP Allowlisting: Restricting Drift
Drift IP Addresses
As of 2025-12-12:
52.2.219.12/3254.196.47.40/3254.82.90.31/32
Source: Drift IP Allowlist Documentation
Implementation: Follow same process as Gainsight (Section 2.1.1), replacing IP addresses.
2.1.3 IP Allowlisting: Restricting HubSpot
HubSpot IP Addresses
HubSpot publishes their IP ranges at: https://knowledge.hubspot.com/integrations/what-are-hubspot-s-ip-addresses
Note: HubSpot has a larger IP range and may change more frequently. Consider:
- More frequent verification (monthly vs quarterly)
- Monitoring HubSpot status page for infrastructure changes
2.2 Restrict Login Hours by Profile
Profile Level: L2 (Hardened)
Description
Limit when users can log into Salesforce based on their role/profile, reducing attack surface during off-hours.
ClickOps Implementation
- Setup → Users → Profiles → [Select Profile]
- Click “Login Hours” button
- Configure allowed hours per day of week
- Save and test with affected users
Use Cases
- Restrict contractor access to business hours only
- Limit admin account login times to reduce exposure
- Geographic-based restrictions (e.g., US-only profiles during US business hours)
3. OAuth & Connected App Security
3.1 Audit and Reduce OAuth Scopes for Connected Apps
Profile Level: L1 (Baseline) CIS Controls: 6.2 (Least Privilege) NIST 800-53: AC-6
Description
Review all Connected Apps (third-party integrations) and ensure they only have minimum required OAuth scopes. Over-permissioned apps increase breach impact.
Rationale
Attack Impact: When Gainsight was breached, attackers had full OAuth scope, allowing complete data exfiltration. Scoped permissions would have limited damage.
ClickOps Implementation
Step 1: Audit Current Connected Apps
- Setup → Apps → Connected Apps → Manage Connected Apps
- Review each app’s “Selected OAuth Scopes”
- Document current scopes and business justification
Step 2: Identify Over-Permissioned Apps Look for apps with:
full- Complete access (almost never needed)api- Full API access (often too broad)refresh_token, offline_access- Persistent access (risk if breached)
Step 3: Reduce Scopes For each over-permissioned app:
- Click app name → Edit Policies
- Modify Selected OAuth Scopes to minimum required:
- Example: Change
fullto specific scopes likechatter_api,custom_permissions
- Example: Change
- Save
- Test integration to ensure functionality maintained
Step 4: Enable OAuth App Approval
- Setup → Security → Session Settings
- Enable: “Require user authorization for OAuth flows”
- This forces users to explicitly approve OAuth apps
Recommended Scope Restrictions by Integration Type
| Integration Type | Recommended Scopes | Avoid |
|---|---|---|
| Customer Success (Gainsight) | api, custom_permissions, specific objects |
full, refresh_token with long expiry |
| Marketing (HubSpot, Drift) | api, chatter_api, limited objects |
full, manage_users |
| Support (Zendesk, Intercom) | api, chatter_api, Case object only |
full, access to all objects |
| Analytics (Tableau) | api, read-only specific objects |
Write access, full |
Compliance Mappings
- SOC 2: CC6.2 (Least Privilege)
- NIST 800-53: AC-6, AC-6(1)
- ISO 27001: A.9.2.3
Code Pack: API Script
# List all Connected Applications with OAuth settings
info "3.1 Querying Connected Applications..."
APP_QUERY="SELECT Id, Name, CreatedDate, CreatedBy.Name, LastModifiedDate FROM ConnectedApplication ORDER BY Name"
APP_RESPONSE=$(sf_tooling_query "${APP_QUERY}") || {
fail "3.1 Failed to query Connected Applications"
increment_failed
summary
exit 0
}
APP_COUNT=$(echo "${APP_RESPONSE}" | jq '.totalSize // 0' 2>/dev/null)
if [ "${APP_COUNT}" -gt 0 ]; then
info "3.1 Found ${APP_COUNT} Connected Application(s):"
echo "${APP_RESPONSE}" | jq -r '.records[] | " - \(.Name) (Created: \(.CreatedDate), By: \(.CreatedBy.Name // "unknown"))"' 2>/dev/null || true
else
info "3.1 No Connected Applications found"
fi
# Audit active OAuth access tokens to identify over-permissioned integrations
info "3.1 Querying active OAuth tokens..."
TOKEN_QUERY="SELECT Id, AppName, UserId, CreatedDate, LastUsedDate FROM OAuthToken ORDER BY LastUsedDate DESC NULLS LAST"
TOKEN_RESPONSE=$(sf_query "${TOKEN_QUERY}" 2>/dev/null || echo '{"records":[],"totalSize":0}')
TOKEN_COUNT=$(echo "${TOKEN_RESPONSE}" | jq '.totalSize // 0' 2>/dev/null)
if [ "${TOKEN_COUNT}" -gt 0 ]; then
info "3.1 Found ${TOKEN_COUNT} active OAuth token(s):"
echo "${TOKEN_RESPONSE}" | jq -r '.records[] | " - \(.AppName // "unnamed") (Last Used: \(.LastUsedDate // "never"), Created: \(.CreatedDate))"' 2>/dev/null || true
# Flag tokens not used in 90+ days
STALE_QUERY="SELECT Id, AppName, UserId, CreatedDate, LastUsedDate FROM OAuthToken WHERE LastUsedDate < LAST_N_DAYS:90"
STALE_RESPONSE=$(sf_query "${STALE_QUERY}" 2>/dev/null || echo '{"records":[],"totalSize":0}')
STALE_COUNT=$(echo "${STALE_RESPONSE}" | jq '.totalSize // 0' 2>/dev/null)
if [ "${STALE_COUNT}" -gt 0 ]; then
warn "3.1 Found ${STALE_COUNT} stale OAuth token(s) unused for 90+ days -- consider revoking:"
echo "${STALE_RESPONSE}" | jq -r '.records[] | " - \(.AppName // "unnamed") (Last Used: \(.LastUsedDate // "never"))"' 2>/dev/null || true
else
pass "3.1 No stale OAuth tokens found (all used within 90 days)"
fi
else
info "3.1 No active OAuth tokens found"
fi
# Check Connected App OAuth policies (admin approval, IP relaxation)
info "3.1 Checking Connected App OAuth policies..."
POLICY_QUERY="SELECT Id, Name, OptionsAllowAdminApprovedUsersOnly, OptionsRefreshTokenValidityMetric FROM ConnectedApplication"
POLICY_RESPONSE=$(sf_tooling_query "${POLICY_QUERY}" 2>/dev/null || echo '{"records":[],"totalSize":0}')
POLICY_COUNT=$(echo "${POLICY_RESPONSE}" | jq '.totalSize // 0' 2>/dev/null)
if [ "${POLICY_COUNT}" -gt 0 ]; then
# Flag apps that do NOT require admin pre-authorization
OPEN_APPS=$(echo "${POLICY_RESPONSE}" | jq '[.records[] | select(.OptionsAllowAdminApprovedUsersOnly != true)]' 2>/dev/null || echo "[]")
OPEN_COUNT=$(echo "${OPEN_APPS}" | jq 'length' 2>/dev/null || echo "0")
if [ "${OPEN_COUNT}" -gt 0 ]; then
warn "3.1 Found ${OPEN_COUNT} Connected App(s) not requiring admin pre-authorization:"
echo "${OPEN_APPS}" | jq -r '.[] | " - \(.Name) -- set OAuth policy to 'Admin approved users are pre-authorized'"' 2>/dev/null || true
else
pass "3.1 All Connected Apps require admin pre-authorization"
fi
else
info "3.1 No Connected App policy data available"
fi
Code Pack: Sigma Detection Rule
detection:
selection_app_change:
EventType: 'SetupAuditTrail'
Action|contains:
- 'createdConnectedApp'
- 'updatedConnectedApp'
- 'deletedConnectedApp'
- 'changedConnectedAppOauth'
- 'changedConnectedAppAttributes'
selection_oauth_policy:
EventType: 'SetupAuditTrail'
Action|contains:
- 'changedOauthPolicy'
- 'updatedOauthToken'
condition: selection_app_change or selection_oauth_policy
fields:
- CreatedDate
- CreatedByContext
- Display
- Section
- DelegateUser
- ResponsibleNamespacePrefix
3.2 Enable Connected App Session-Level Security
Profile Level: L2 (Hardened)
Description
Configure Connected Apps to inherit session security policies (IP restrictions, timeout) from user’s profile.
ClickOps Implementation
- Setup → Apps → Connected Apps → [App Name]
- Edit Policies
- Session Timeout: Set to “2 hours” or less (not “Never expires”)
- Refresh Token Policy: “Expire after 30 days” (not “Refresh token valid indefinitely”)
- Enable: “Enforce IP restrictions”
4. Data Security
4.1 Enable Field-Level Encryption for Sensitive Data
Profile Level: L2 (Hardened) Requires: Salesforce Shield
Description
Encrypt sensitive fields (SSN, credit card, health data) at rest using Salesforce Shield Platform Encryption.
ClickOps Implementation
- Setup → Security → Platform Encryption
- Generate tenant secret (store securely!)
- Select fields to encrypt:
- Custom fields marked as sensitive
- Standard fields: SSN, Credit Card, etc.
- Enable encryption per field
- Test: Encrypted fields show lock icon
Limitations
- Encrypted fields cannot be used in:
- WHERE clauses (except equality)
- ORDER BY
- Formula fields (in some cases)
- Requires Shield add-on (~$25/user/month)
5. Monitoring & Detection
5.1 Enable Event Monitoring for API Anomalies
Profile Level: L1 (Baseline) Requires: Salesforce Shield or Event Monitoring add-on
Description
Enable Salesforce Event Monitoring to detect anomalous API usage patterns that could indicate compromised integrations.
ClickOps Implementation
- Setup → Event Monitoring → Event Manager
- Enable these event types:
- API (all API calls)
- Login (authentication events)
- URI (page views)
- Report Export (data exfiltration indicator)
- Configure storage: EventLogFile (24hr) or Event Monitoring Analytics (30 days)
Detection Queries
Code Pack: API Script
# Query available EventLogFile types to verify Event Monitoring is active
info "5.1 Checking EventLogFile availability..."
LOG_QUERY="SELECT Id, EventType, LogDate, LogFileLength FROM EventLogFile ORDER BY LogDate DESC LIMIT 50"
LOG_RESPONSE=$(sf_query "${LOG_QUERY}") || {
fail "5.1 Failed to query EventLogFile -- Event Monitoring may not be enabled"
fail "5.1 Enable via Setup > Event Monitoring Settings or purchase Salesforce Shield"
increment_failed
summary
exit 0
}
LOG_COUNT=$(echo "${LOG_RESPONSE}" | jq '.totalSize // 0' 2>/dev/null)
if [ "${LOG_COUNT}" -gt 0 ]; then
pass "5.1 Event Monitoring is active -- found ${LOG_COUNT} recent log file(s)"
# Summarize event types available
EVENT_TYPES=$(echo "${LOG_RESPONSE}" | jq -r '[.records[].EventType] | unique | sort | .[]' 2>/dev/null || true)
TYPE_COUNT=$(echo "${EVENT_TYPES}" | grep -c . 2>/dev/null || echo "0")
info "5.1 ${TYPE_COUNT} event type(s) available:"
echo "${EVENT_TYPES}" | while read -r etype; do
echo " - ${etype}"
done
else
warn "5.1 No EventLogFile records found -- Event Monitoring may not be enabled"
warn "5.1 Enable via Setup > Event Monitoring Settings"
fi
# Look for API-related event types indicating anomaly detection
info "5.1 Checking for API anomaly event types..."
API_LOG_QUERY="SELECT Id, EventType, LogDate, LogFileLength FROM EventLogFile WHERE EventType IN ('ApiTotalUsage', 'API', 'RestApi', 'BulkApi', 'Login', 'LoginAs') ORDER BY LogDate DESC LIMIT 20"
API_LOG_RESPONSE=$(sf_query "${API_LOG_QUERY}" 2>/dev/null || echo '{"records":[],"totalSize":0}')
API_LOG_COUNT=$(echo "${API_LOG_RESPONSE}" | jq '.totalSize // 0' 2>/dev/null)
if [ "${API_LOG_COUNT}" -gt 0 ]; then
pass "5.1 Found ${API_LOG_COUNT} API-related event log(s)"
echo "${API_LOG_RESPONSE}" | jq -r '.records[] | " - \(.EventType) on \(.LogDate) (\(.LogFileLength) bytes)"' 2>/dev/null || true
else
warn "5.1 No API-related event logs found -- enable API event types in Event Monitoring"
fi
# Audit recent failed login attempts for suspicious activity
info "5.1 Auditing recent failed login attempts..."
FAILED_QUERY="SELECT Id, LoginTime, SourceIp, Status, Application, UserId, LoginType FROM LoginHistory WHERE Status = 'Failed' ORDER BY LoginTime DESC LIMIT 50"
FAILED_RESPONSE=$(sf_query "${FAILED_QUERY}" 2>/dev/null || echo '{"records":[],"totalSize":0}')
FAILED_COUNT=$(echo "${FAILED_RESPONSE}" | jq '.totalSize // 0' 2>/dev/null)
if [ "${FAILED_COUNT}" -gt 0 ]; then
warn "5.1 Found ${FAILED_COUNT} recent failed login attempt(s):"
# Group by source IP for anomaly detection
UNIQUE_FAIL_IPS=$(echo "${FAILED_RESPONSE}" | jq -r '[.records[].SourceIp] | unique | .[]' 2>/dev/null || true)
echo "${UNIQUE_FAIL_IPS}" | while read -r ip; do
COUNT=$(echo "${FAILED_RESPONSE}" | jq "[.records[] | select(.SourceIp == \"${ip}\")] | length" 2>/dev/null || echo "?")
echo " - ${ip}: ${COUNT} failed attempt(s)"
done
# Flag IPs with 5+ failures (brute force indicator)
echo "${UNIQUE_FAIL_IPS}" | while read -r ip; do
COUNT=$(echo "${FAILED_RESPONSE}" | jq "[.records[] | select(.SourceIp == \"${ip}\")] | length" 2>/dev/null || echo "0")
if [ "${COUNT}" -ge 5 ]; then
warn "5.1 ALERT: ${ip} has ${COUNT} failed logins -- possible brute force"
fi
done
else
pass "5.1 No recent failed login attempts found"
fi
Code Pack: Sigma Detection Rule
detection:
selection_bulk_api:
EventType:
- 'BulkApi'
- 'ApiBulkApiHard'
selection_data_export:
EventType: 'DataExport'
selection_large_query:
EventType:
- 'API'
- 'RestApi'
ROWS_PROCESSED|gte: 10000
condition: selection_bulk_api or selection_data_export or selection_large_query
fields:
- EventDate
- Username
- SourceIp
- ROWS_PROCESSED
- ENTITY_NAME
- CLIENT_NAME
- REQUEST_STATUS
Alert Configuration
Using Salesforce Shield:
- Create custom report with anomaly query
- Subscribe to report with alert threshold
- Configure email/Slack notification
Using Third-Party SIEM: Export EventLogFile daily to:
- Splunk
- Datadog
- Sumo Logic
- AWS Security Lake
6. Third-Party Integration Security
6.1 Integration Risk Assessment Matrix
Before allowing any third-party integration, assess risk:
| Risk Factor | Low | Medium | High |
|---|---|---|---|
| Data Access | Read-only, limited objects | Read most objects | Write access, full API |
| OAuth Scopes | Specific scopes only | api scope |
full scope |
| Session Duration | <2 hours | 2-8 hours | >8 hours, refresh tokens |
| IP Restriction | Static IPs, allowlisted | Some static IPs | Dynamic IPs, no allowlist |
| Vendor Security | SOC 2 Type II, recent audit | SOC 2 Type I | No SOC 2 |
Decision Matrix:
- 0-5 points: Approve with standard controls
- 6-10 points: Approve with enhanced monitoring
- 11-15 points: Require additional security measures or reject
6.2 Common Integrations and Recommended Controls
Gainsight (Customer Success Platform)
Data Access: High (needs Account, Contact, Case, Custom Objects) Recommended Controls:
- ✅ IP allowlisting (Section 2.1.1)
- ✅ Reduce OAuth scopes from
fulltoapi+ specific objects - ✅ Enable Event Monitoring for bulk queries
- ✅ 30-day refresh token expiration
Drift (Marketing/Chat Platform)
Data Access: Medium (needs Lead, Contact, Account) Recommended Controls:
- ✅ IP allowlisting (Section 2.1.2)
- ✅ Read-only access to Lead/Contact
- ✅ Restrict to marketing team profile
- ⚠️ Note: Drift was breached in 2025 - high-risk integration
HubSpot (Marketing Automation)
Data Access: Medium-High Recommended Controls:
- ✅ IP allowlisting (Section 2.1.3) with monthly verification
- ✅ Bidirectional sync monitoring (alert on unexpected write operations)
- ✅ Field-level restrictions (don’t sync SSN, financial data)
7. Compliance Quick Reference
SOC 2 Trust Services Criteria Mapping
| Control ID | Salesforce Control | Guide Section |
|---|---|---|
| CC6.1 | MFA for all users | 1.1 |
| CC6.2 | OAuth scope reduction | 3.1 |
| CC6.6 | IP allowlisting | 2.1 |
| CC7.2 | Event Monitoring | 5.1 |
NIST 800-53 Rev 5 Mapping
| Control | Salesforce Control | Guide Section |
|---|---|---|
| AC-3 | IP restrictions | 2.1 |
| AC-6 | Least privilege OAuth | 3.1 |
| IA-2(1) | MFA enforcement | 1.1 |
| AU-6 | Event monitoring | 5.1 |
Appendix A: Edition Compatibility
| Control | Professional | Enterprise | Unlimited | Performance | Shield Required |
|---|---|---|---|---|---|
| MFA | ✅ | ✅ | ✅ | ✅ | ❌ |
| IP Allowlisting | ❌ | ✅ | ✅ | ✅ | ❌ |
| OAuth Scoping | ✅ | ✅ | ✅ | ✅ | ❌ |
| Event Monitoring | ❌ | Add-on | Add-on | Add-on | ✅ |
| Field Encryption | ❌ | ❌ | ❌ | ❌ | ✅ |
Appendix B: References
Official Salesforce Documentation:
- Salesforce Help Center
- Salesforce Security Best Practices
- Salesforce Security Guide (PDF)
- Network Access (IP Allowlisting)
- Connected Apps and OAuth
- Event Monitoring
API & Developer Resources:
Trust & Compliance:
- Salesforce Compliance Site
- SOC 2 Type II, ISO 27001, ISO 27017, ISO 27018 – via Salesforce Compliance Documents
Integration Vendor IP Documentation:
Supply Chain Incident Reports:
Security Incidents:
- Salesloft/Drift OAuth Supply Chain Attack (August-September 2025): Attackers compromised Salesloft/Drift infrastructure and exfiltrated OAuth tokens, affecting 700+ Salesforce orgs. Gainsight breach separately impacted 200+ orgs via stolen OAuth tokens (November 2025). IP allowlisting proved effective – Okta was targeted but protected because they had IP restrictions configured.
Changelog
| Date | Version | Maturity | Changes | Author |
|---|---|---|---|---|
| 2025-12-12 | 0.1.0 | draft | Initial Salesforce hardening guide with focus on integration security | Claude Code (Opus 4.5) |
Next Steps:
- Review your current Salesforce configuration against L1 (Baseline) controls
- Implement IP allowlisting for high-risk integrations (Gainsight, Drift, HubSpot)
- Audit Connected App OAuth scopes and reduce over-permissions
- Enable Event Monitoring for API anomaly detection
- Establish quarterly review process for integration security
Questions or Improvements?
- Open an issue: GitHub Issues
- Contribute: Contributing Guide