Square Hardening Guide
Commerce platform hardening for Square including SSO configuration, team permissions, and API security
Overview
Square is a comprehensive commerce platform serving millions of businesses for payments, point-of-sale, and business management. As a platform handling payment and customer data, Square security configurations directly impact PCI compliance and business operations.
Intended Audience
- Security engineers managing commerce platforms
- IT administrators configuring Square
- Business owners managing Square access
- GRC professionals assessing retail security
How to Use This Guide
- L1 (Crawl): Essential controls for all organizations
- L2 (Walk): Enhanced controls for security-sensitive environments
- L3 (Run): Strictest controls for regulated industries
Scope
This guide covers Square Dashboard security including SSO, team permissions, device security, and API management.
Table of Contents
1. Authentication & SSO
1.1 Configure Single Sign-On
Profile Level: L1 (Crawl)
| Framework | Control |
|---|---|
| CIS Controls | 6.3, 12.5 |
| NIST 800-53 | IA-2, IA-8 |
Description
Configure SSO for Square Dashboard access (Square for Enterprise).
Verification note (2026-08): Square’s SSO documentation is not externally verifiable — the Square help center is a single-page application that returns HTTP 200 with the support homepage for pages that do not exist, so the “Square for Enterprise” plan requirement and the Security → Single Sign-On path below could not be corroborated against a fetchable first-party page. Confirm availability, plan eligibility, and the exact console path with Square support or in the live Dashboard before relying on this control.
Rationale
Why This Matters:
- Centralizes Square Dashboard authentication in your corporate IdP, enforcing MFA and conditional access on every login
- Local Square password logins bypass IdP controls and are prime targets for credential stuffing and phishing
- IdP-driven deprovisioning removes access the moment an employee leaves, eliminating orphaned accounts that retain standing access to payment and customer data
- A single compromised Square login can expose transaction history, customer PII, and payout banking details
Attack Prevented: Credential theft, phishing, password reuse, orphaned-account access
Prerequisites
- Square for Enterprise plan
- Account owner access
- SAML 2.0 compatible IdP
ClickOps Implementation
Step 1: Access SSO Settings
- Navigate to: Square Dashboard → Account & Settings → Security
- Find Single Sign-On section
Step 2: Configure SSO
- Enable SSO
- Configure IdP settings
- Test authentication
Step 3: Enforce SSO
- Enable SSO enforcement
- Configure exceptions
- Document fallback procedures
Automation: ClickOps only — Square exposes no write interface for this setting (OAuth permissions reference, 2026-09-24). None of the API’s permission scopes covers Dashboard sign-in or SSO, the Terraform Registry carries no Square provider, and Square publishes no first-party CLI.
Time to Complete: ~1-2 hours
1.2 Enforce Two-Factor Authentication
Profile Level: L1 (Crawl)
| Framework | Control |
|---|---|
| CIS Controls | 6.5 |
| NIST 800-53 | IA-2(1) |
Description
Require 2FA for all Square accounts.
Rationale
Why This Matters:
- Two-factor authentication blocks account takeover even when a password is phished, leaked, or reused from another breach
- Square accounts control payments, refunds, and payout bank accounts, so a password alone is insufficient protection for financial operations
- Requiring 2FA across the whole team closes the weakest-link gap where one unprotected member becomes the entry point
- PCI DSS requires multi-factor authentication for access to the cardholder data environment
Attack Prevented: Credential stuffing, phishing, password reuse, account takeover
ClickOps Implementation
Step 1: Enable 2FA on Your Own Sign-In
- Navigate to: Settings → Account & Settings → Personal information → Sign in & security
- Under Two-step verification, select Enable
- Choose a verification method — Text message, Voice call, or Authentication app — and complete verification, then add a backup method from Manage two-step verification
Step 2: Require for Team
- Navigate to: Settings → Account & Settings → My business → Security
- Under Business two-step verification, toggle on Two-step verification for team members
- Existing team members receive setup instructions by email and are required to enroll the next time they sign in; new team members enroll when they create their account (Set up two-step verification)
- Verify enrollment and follow up with any team member who has not completed setup
Automation: ClickOps only — Square exposes no write interface for this setting (OAuth permissions reference, 2026-09-24). No API permission scope covers account sign-in security.
2. Access Controls
2.1 Configure Team Permissions
Profile Level: L1 (Crawl)
| Framework | Control |
|---|---|
| CIS Controls | 5.4 |
| NIST 800-53 | AC-6 |
Description
Implement least privilege using Square permissions.
Rationale
Why This Matters:
- Least-privilege permission sets ensure each team member can only reach the data and actions their role requires
- Over-broad access lets a single compromised staff account expose customer records, sales reports, and settings far beyond its job function
- Separating sales, reporting, customer-data, and settings access contains the blast radius of any one account compromise
- Granular permissions create accountability and make insider misuse easier to detect during access reviews
Attack Prevented: Privilege escalation, insider data theft, lateral movement, excessive-access abuse
ClickOps Implementation
Step 1: Review Permission Sets
- Navigate to: Staff → Team → Permissions
- Review the existing permission sets and the team members assigned to each
- Select Create permission set, choose a permission level (Standard, Enhanced, or Full), then select Customize to set individual permissions
Plan note: how many custom permission sets you can create depends on your subscription — Square Shifts Free and Square for Restaurants Free allow one, Square Shifts Plus and Square for Retail Plus allow two, and Square Advanced Access, Square for Retail Premium, Square Appointments Premium, Square Plus and Square Premium allow unlimited sets (Create and edit permission sets).
Step 2: Assign Minimum Access
- Assign minimum necessary permissions
- Separate by function:
- Sales access
- Reports access
- Customer data access
- Settings access
- Reserve the Full access toggle for the few people who need it — it grants every permission except managing bank accounts and automatically makes the holder an authorized representative (see 2.3)
- Regular access reviews
Automation: ClickOps only — Square exposes no write interface for this setting (Team API overview, 2026-09-24). Square lists “get or set permissions for team members” among the operations its APIs cannot perform.
2.2 Configure Location Access
Profile Level: L2 (Walk)
| Framework | Control |
|---|---|
| CIS Controls | 5.4 |
| NIST 800-53 | AC-6 |
Description
Control team access to specific locations.
Rationale
Why This Matters:
- Scoping team members to only their assigned locations limits exposure of sales, customer, and payout data across the business
- A compromised or rogue account confined to one location cannot pull reports or process refunds for the entire organization
- Location-level segmentation enforces separation between production sites and test or pilot locations
- Cross-location access reviews surface accounts that have accumulated unnecessary reach over time
Attack Prevented: Lateral movement, cross-location data exposure, excessive-access abuse
ClickOps Implementation
Step 1: Assign Locations per Team Member
- Navigate to: Staff → Team → Team members, then select a team member
- Next to Permissions, select Edit and assign only the locations this person needs — the assigned locations determine which data, menus, and features they can reach (Add and manage team members)
- Keep production locations separate from test or pilot locations
- Audit cross-location assignments during every access review
Code Implementation
The Team API returns each team member’s assigned_locations. On an account with two or more active locations, the pack flags every non-owner team member whose assignment type is not EXPLICIT_LOCATIONS — the alternative, ALL_CURRENT_AND_FUTURE_LOCATIONS, also reaches every location created later. With exactly one active location there is nothing to separate yet, so the pack reports the control as not applicable and lists those same members for review instead, because a second location would reach them automatically. It treats an empty team list, or an account with no active locations, as a failed scan rather than a clean one, because every seller has at least an owner and a main location (Locations API, SearchTeamMembers, TeamMemberAssignedLocationsAssignmentType). A response that carries an error, on any page, also stops the scan without a verdict. It only reads; remediation through the API is UpdateTeamMember with the EMPLOYEES_WRITE permission (UpdateTeamMember).
Code Pack: API Script
audit() {
local cursor="" pages=0 body members total owners locs broad
: > "${ITEMS_FILE}"
while :; do
body=$(jq -nc --arg c "${cursor}" \
'{query: {filter: {status: "ACTIVE"}}, limit: 200} + (if $c == "" then {} else {cursor: $c} end)')
sq "${SQUARE_BASE}/team-members/search" "${body}"
jq -c '.team_members[]?' "${BODY_FILE}" >> "${ITEMS_FILE}"
cursor=$(jq -r '.cursor // ""' "${BODY_FILE}")
pages=$((pages + 1))
[ -n "${cursor}" ] || break
if [ "${pages}" -ge 500 ]; then
echo "PRECONDITION: SearchTeamMembers still returned a cursor after 500 pages — the scan is incomplete, so no verdict is given." >&2
exit 2
fi
done
members=$(jq -s '.' "${ITEMS_FILE}")
total=$(j "${members}" 'length')
if [ "${total}" -eq 0 ]; then
echo "PRECONDITION: SearchTeamMembers returned 0 ACTIVE team members — every account has at least its owner, so nothing was scanned." >&2
exit 2
fi
sq "${SQUARE_BASE}/locations"
locs=$(jq '[.locations[]? | select(.status == "ACTIVE")] | length' "${BODY_FILE}")
if [ "${locs}" -eq 0 ]; then
echo "PRECONDITION: ListLocations returned 0 ACTIVE locations — every seller has a main location, so nothing was scanned (TRAP 3)." >&2
exit 2
fi
owners=$(j "${members}" '[.[] | select(.is_owner == true)] | length')
echo "Square 2.2 — team member location assignments"
echo " active team members scanned: ${total} (account owner: ${owners}) · active locations: ${locs}"
broad=$(j "${members}" -c '[.[] | select((.is_owner // false) | not)
| select((.assigned_locations.assignment_type // "UNSET") != "EXPLICIT_LOCATIONS")]')
j "${broad}" -r '.[] | " - team_member=…\((.id // "unknown")[-6:]) assignment_type=\(.assigned_locations.assignment_type // "UNSET")"'
if [ "${locs}" -eq 1 ]; then
echo "NOT APPLICABLE: 1 active location — location scoping has nothing to separate yet (TRAP 3)."
if [ "$(j "${broad}" 'length')" -gt 0 ]; then
echo "REVIEW: $(j "${broad}" 'length') non-owner team member(s) listed above are not on EXPLICIT_LOCATIONS, so any location added later will reach them automatically — re-check them before a second location opens."
fi
return 0
fi
if [ "$(j "${broad}" 'length')" -gt 0 ]; then
echo "FINDING: $(j "${broad}" 'length') non-owner team member(s) are not limited to explicit locations (TRAP 1)."
echo " Assign each one EXPLICIT_LOCATIONS in Staff > Team, or PUT /v2/team-members/{id}."
return 1
fi
echo "COMPLIANT: every non-owner ACTIVE team member is assigned EXPLICIT_LOCATIONS."
return 0
}
2.3 Limit Admin Access
Profile Level: L1 (Crawl)
| Framework | Control |
|---|---|
| CIS Controls | 5.4 |
| NIST 800-53 | AC-6(1) |
Description
Protect the account owner and minimize the team members who hold Full access.
Rationale
Why This Matters:
- The account owner holds the highest privilege in Square (billing, banking, team management, and full data access), and a team member with Full access holds every permission except managing bank accounts, so the number of Full-access holders must be tightly limited
- Every additional Full-access holder expands the attack surface and the chance of a single compromised credential controlling the whole account
- Full access also makes the holder an authorized representative, who can discuss account-specific details with Square Support and ask it to update business information, so every representative is also a route to account changes through Square Support
- Requiring 2FA and monitoring activity on owner accounts detects misuse before it escalates
- Tight owner control reduces the risk of standing access lingering after a privileged employee departs
Attack Prevented: Privilege escalation, account takeover, insider abuse, orphaned-admin access
ClickOps Implementation
Step 1: Inventory Privileged Access
- Navigate to: Staff → Team → Permissions
- List every permission set at the Full level or with Full access toggled on, and the team members assigned to each (Create and edit permission sets)
- Navigate to: Settings → Account & Settings → My business → Security and select Edit authorized representatives to review who holds that status (Add and manage authorized representatives)
Step 2: Apply Restrictions
- Limit Full access to 2-3 trusted individuals; the account owner is a separate identity — Square’s Team API models it as the single team member with
is_ownerset (TeamMember) - Remove authorized-representative status from anyone who does not need to deal with Square Support on the business’s behalf (removing Full access removes it automatically)
- Require two-step verification for the owner and every Full-access holder (1.2)
- Monitor activity
Automation: ClickOps only — Square exposes no write interface for this setting (Team API overview, 2026-09-24). Permission sets cannot be read or set through the API, and the team member that represents the account owner cannot be updated through it; the API reports only which team member is the owner, which says nothing about Full-access reach.
3. Device Security
3.1 Configure Device Management
Profile Level: L2 (Walk)
| Framework | Control |
|---|---|
| CIS Controls | 1.1 |
| NIST 800-53 | CM-8 |
Description
Manage Square devices and terminals.
Rationale
Why This Matters:
- Square terminals and POS devices sit in physically exposed retail environments where theft and tampering are real threats
- Device passcodes and automatic logout prevent an unattended or stolen terminal from processing fraudulent transactions or exposing customer data
- An accurate device inventory makes rogue or unrecognized hardware immediately visible
- Monitoring device activity surfaces anomalous use such as logins from unexpected devices or off-hours transactions
Attack Prevented: Physical device theft, unauthorized POS access, terminal tampering, fraudulent transactions
ClickOps Implementation
Step 1: Inventory Devices
- Navigate to: Settings → Device Management → Devices
- Review all registered devices, filtering by Status, Location, Device type, Installed apps, or Mode (Set up device codes)
- Document each device’s purpose, and remove devices you no longer use: select the three dots (•••) next to the device, then Forget
Step 2: Configure Security
- Enable passcodes on each point of sale: in the Square POS app, open ≡ More → Settings → Security and toggle on Passcodes; prefer personal passcodes over a shared team passcode so sales and actions stay attributable to one person (Require passcodes at point of sale)
- Configure automatic logout with a passcode timeout: in the Dashboard, Settings → Device Management → Modes, select a location, select Manage, then set Security → Timeout (or, on the device, ≡ More → Security → After timeout)
- Monitor device activity
Code Implementation
The Devices API inventories Terminal API devices only — hardware not paired through the Terminal API does not appear — so an empty result means “no Terminal API devices”, never “no devices”. The pack lists what the API returns and flags every device whose status category is not AVAILABLE — OFFLINE, NEEDS_ATTENTION, or missing (ListDevices, DeviceStatusCategory). It also lists devices whose information has not been updated within a configurable window, or whose update time is missing or unreadable, for review only: updated_at records the most recent change to any device field, not device activity, so it cannot show whether a device is in service (DeviceAttributes). Device passcodes are ClickOps only: Square lists setting a passcode among the operations its APIs cannot perform (Team API overview, 2026-09-24).
Code Pack: API Script
audit() {
local cursor="" pages=0 devices total unhealthy stale now
: > "${ITEMS_FILE}"
while :; do
sq "${SQUARE_BASE}/devices?limit=100${cursor:+&cursor=$(jq -rn --arg c "${cursor}" '$c|@uri')}"
jq -c '.devices[]?' "${BODY_FILE}" >> "${ITEMS_FILE}"
cursor=$(jq -r '.cursor // ""' "${BODY_FILE}")
pages=$((pages + 1))
[ -n "${cursor}" ] || break
if [ "${pages}" -ge 500 ]; then
echo "PRECONDITION: ListDevices still returned a cursor after 500 pages — the inventory is incomplete, so no verdict is given." >&2
exit 2
fi
done
devices=$(jq -s '.' "${ITEMS_FILE}")
total=$(j "${devices}" 'length')
now=$(date -u +%s)
echo "Square 3.1 — Terminal API device inventory (TRAP 1: other hardware is not listed)"
echo " Terminal API devices returned: ${total}"
j "${devices}" -r '.[] | " - device=…\((.id // "unknown")[-6:]) type=\(.attributes.type // "?") model=\(.attributes.model // "?") version=\(.attributes.version // "?") status=\(.status.category // "UNKNOWN") info_updated_at=\(.attributes.updated_at // "(never)")"'
if [ "${total}" -eq 0 ]; then
echo "NO TERMINAL API DEVICES: nothing to inventory through the API. Review all other hardware in the Dashboard (TRAP 1)."
return 0
fi
# TRAP 3: a missing category counts as not AVAILABLE — an unreadable status is never healthy.
unhealthy=$(j "${devices}" '[.[] | select((.status.category // "UNKNOWN") != "AVAILABLE")] | length')
# TRAP 4: RFC 3339 by hand — fractional seconds dropped, a numeric offset applied;
# anything unreadable is null, and null counts for review.
stale=$(j "${devices}" --argjson now "${now}" --argjson days "${STALE_DAYS}" '
def epoch:
if type != "string" then null else
[capture("^(?<b>[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2})(\\.[0-9]+)?(?<z>Z|[+-][0-9]{2}:[0-9]{2})$")]
| if length == 0 then null else .[0] as $m
| (try (($m.b + "Z") | fromdateiso8601) catch null) as $t
| if $t == null then null
elif $m.z == "Z" then $t
else $t - (($m.z[1:3] | tonumber) * 3600 + ($m.z[4:6] | tonumber) * 60) * (if $m.z[0:1] == "+" then 1 else -1 end)
end
end
end;
[.[] | (.attributes.updated_at | epoch) as $t
| select($t == null or ($now - $t) > ($days * 86400))]
| length')
if [ "${stale}" -gt 0 ]; then
echo "REVIEW: ${stale} Terminal API device(s) have device information not updated in ${STALE_DAYS} days, or no readable updated_at (updated_at records any change to device information, not device activity — TRAP 3)."
fi
if [ "${unhealthy}" -gt 0 ]; then
echo "FINDING: ${unhealthy} Terminal API device(s) report a status other than AVAILABLE (OFFLINE, NEEDS_ATTENTION or missing) — confirm each is still in service and physically accounted for, or unpair it."
return 1
fi
echo "COMPLIANT: all ${total} Terminal API device(s) report status AVAILABLE."
return 0
}
3.2 Configure API Security
Profile Level: L2 (Walk)
| Framework | Control |
|---|---|
| CIS Controls | 3.11 |
| NIST 800-53 | SC-12 |
Description
Secure Square API access — personal access tokens, OAuth access and refresh tokens, and the connected applications that hold them.
Rationale
Why This Matters:
- Square API access tokens can read and write payments, customers, and inventory programmatically, so a leaked token is equivalent to a compromised account
- A personal access token is unrestricted: Square documents that it grants full API access to the account that issued it, with no permission scoping, so it must never be hardcoded, committed, or shared (Access tokens)
- OAuth access tokens are short-lived by design (30 days) while refresh tokens can persist indefinitely, so refresh-token custody — not access-token custody — is the real long-term risk
- Removing unused connected applications eliminates dormant integrations that retain access no one is monitoring
- Using the sandbox for testing keeps real payment data and live credentials out of development workflows
Attack Prevented: API token theft, third-party integration abuse, credential leakage, unauthorized data access, persistent access via unrevoked refresh tokens
ClickOps Implementation
Step 1: Review Applications
- Navigate to: Developer Console (developer.squareup.com/apps) and review every application your account owns — each application’s Credentials page holds an access token with permission to update all Square account data (Developer Console)
- Review the third-party applications authorized on the seller account: in the Square Dashboard, navigate to Settings → App integrations
- Disconnect apps you no longer use — select the three dots next to the app, then Disconnect App (Integrate third-party applications); disconnecting an application revokes all of its OAuth tokens for the seller (OAuth best practices)
Step 2: Secure Credentials
- Store personal access tokens in a secrets manager — treat one as equivalent to full account credentials, because Square grants it unrestricted API access to the issuing account
- Never hardcode a token in application source, configuration committed to version control, or client-side code, and never share one between environments or people
- Use sandbox credentials for all testing so production tokens never enter development workflows
- Rotate credentials regularly and revoke any token whose custody is uncertain
Step 3: Manage OAuth Token Lifetimes
Square documents these lifetimes for OAuth credentials (OAuth API overview) — build rotation around them rather than assuming tokens expire on their own:
| Credential | Lifetime |
|---|---|
| OAuth access token | Expires 30 days after issue |
| Refresh token (authorization code flow) | Does not expire until explicitly revoked |
| Refresh token (PKCE flow) | Single use, expires after 90 days |
- Refresh access tokens on a schedule shorter than 30 days rather than waiting for API failures
- Treat authorization-code refresh tokens as long-lived secrets — they remain valid indefinitely until revoked, so revocation (not expiry) is the only way to end access
- For PKCE integrations, persist the newly returned refresh token on every exchange; the previous one is consumed
- Subscribe to the
oauth.authorization.revokedwebhook so your integration learns immediately when a merchant or Square revokes an authorization, instead of discovering it through failed API calls
Code Implementation
RetrieveTokenStatus (POST /oauth2/token/status) introspects the token it is called with — an OAuth access token or an application’s access token — and returns its scopes and expires_at, which is empty when the token never expires (RetrieveTokenStatus). The pack flags a never-expiring token and a token close to expiry, and lists any write scopes for review. A successful response that describes no token (no scopes and no client_id) is treated as a failed call, never as a token that does not expire. It sees only the token it is given, so it cannot enumerate other tokens or connected applications; Step 1 stays in the console.
Code Pack: API Script
audit() {
local code rc expires writes findings=0 left exp_epoch
set +e
code=$(curl -sS --max-time "${HTTP_TIMEOUT}" -o "${BODY_FILE}" -w '%{http_code}' "${SQUARE_HOST}/oauth2/token/status" \
-H "Authorization: Bearer ${SQUARE_ACCESS_TOKEN}" -H "Square-Version: ${SQUARE_VERSION}" \
-H "Content-Type: application/json" --data '{}')
rc=$?
set -e
if [ "${rc}" -ne 0 ]; then echo "PRECONDITION: /oauth2/token/status — no HTTP response (curl exit ${rc})" >&2; exit 2; fi
if [ "${code}" != "200" ]; then
echo "PRECONDITION: /oauth2/token/status returned HTTP ${code} ($(jq -r '([.errors[]? | "\(.category)/\(.code)"] | join(", ")) as $e | if $e != "" then $e else (.type // .message // "no error body") end' "${BODY_FILE}" 2>/dev/null || echo "unparseable body"))" >&2
exit 2
fi
# TRAP 4: a 200 must actually describe a token before its missing expires_at means anything.
if ! jq -e '((.errors // []) | length) == 0
and ((.scopes | type) == "array" or ((.client_id // "") | length) > 0)' "${BODY_FILE}" >/dev/null 2>&1; then
echo "PRECONDITION: /oauth2/token/status returned HTTP 200 without introspection data (no scopes list or client_id, or an errors array) — no token was described (TRAP 4)." >&2
exit 2
fi
expires=$(jq -r '.expires_at // ""' "${BODY_FILE}")
if [ -n "${expires}" ]; then
# TRAP 5: RFC 3339 by hand — fractional seconds dropped, a numeric offset applied.
exp_epoch=$(jq -rn --arg e "${expires}" '
[$e | capture("^(?<b>[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2})(\\.[0-9]+)?(?<z>Z|[+-][0-9]{2}:[0-9]{2})$")] | .[0] as $m
| (($m.b + "Z") | fromdateiso8601)
- (if $m.z == "Z" then 0 else (($m.z[1:3] | tonumber) * 3600 + ($m.z[4:6] | tonumber) * 60) * (if $m.z[0:1] == "+" then 1 else -1 end) end)' 2>/dev/null) \
&& [ -n "${exp_epoch}" ] && [ "${exp_epoch}" != "null" ] \
|| { echo "PRECONDITION: expires_at '${expires}' is not an RFC 3339 timestamp — cannot judge expiry." >&2; exit 2; }
fi
writes=$(jq -r '[.scopes[]? | select(endswith("_WRITE"))] | join(" ")' "${BODY_FILE}")
echo "Square 3.2 — introspection of the token in use (TRAP 2: this token only)"
echo " scopes: $(jq '.scopes | length' "${BODY_FILE}") total · write scopes: ${writes:-(none)}"
echo " expires_at: ${expires:-(never — TRAP 3)}"
if [ -z "${expires}" ]; then
echo "FINDING: this token never expires. Treat it as a full-account credential: keep it only in a secrets manager, never share it across environments, and prefer a scoped OAuth token for integrations."
findings=$((findings + 1))
else
left=$(( (exp_epoch - $(date -u +%s)) / 86400 ))
echo " days until expiry: ${left}"
if [ "${left}" -lt "${MIN_DAYS}" ]; then
echo "FINDING: token expires in ${left} day(s) — refresh on a schedule shorter than 30 days instead of waiting for API failures."
findings=$((findings + 1))
fi
fi
if [ -n "${writes}" ]; then
echo "REVIEW: the token carries write scopes (${writes}). If this integration only reads, re-authorize it with *_READ scopes only."
fi
[ "${findings}" -eq 0 ] && { echo "COMPLIANT: token expires and is not due for refresh within ${MIN_DAYS} days."; return 0; }
return 1
}
3.3 Verify Webhook Signatures
Profile Level: L1 (Crawl)
| Framework | Control |
|---|---|
| CIS Controls | 3.10 |
| NIST 800-53 | SC-8, SI-7 |
Description
Validate the x-square-hmacsha256-signature header on every Square webhook notification before acting on its payload, using a constant-time comparison against an HMAC-SHA-256 computed from your webhook signature key, the exact notification URL, and the raw request body.
Rationale
Why This Matters:
- A webhook endpoint is a publicly reachable URL: anyone who discovers it can POST a forged payment, refund, or order event unless the signature is checked, so signature verification is the only thing separating a Square event from an attacker’s
- Square computes the signature over the concatenation of the notification URL and the raw request body, so verifying against a re-serialized or parsed body silently fails and tempts developers to disable the check entirely
- Square’s documentation explicitly warns that a naive string comparison leaks information through timing analysis; a constant-time comparison closes that side channel
- The signature key is per-subscription — leaking or reusing it across environments lets a lower-trust environment forge production events
Attack Prevented: Webhook forgery, fraudulent event injection, replay of attacker-crafted payment notifications, timing-analysis recovery of the signature
Prerequisites
- A webhook subscription created in the Square Developer Console
- The subscription’s webhook signature key, stored in a secrets manager
ClickOps Implementation
Step 1: Retrieve the Signature Key
- Navigate to: Developer Console → Open your application → Webhooks → Subscriptions
- Select the subscription’s name to open Endpoint Details, then select Show in the Signature Key box and copy the key (Subscribe to event notifications)
- Store the key in your secrets manager — never in source control
Step 2: Record the Exact Notification URL
- Note the notification URL exactly as registered, including scheme, host, path, and any trailing characters
- Any mismatch between the registered URL and the URL used in the HMAC input produces a failed verification, so the value your code uses must be configuration, not a reconstruction from request headers
Step 3: Verify Every Notification
- Read the
x-square-hmacsha256-signatureheader from the incoming request - Concatenate the notification URL with the raw, unparsed request body
- Compute HMAC-SHA-256 over that string using the signature key, then Base64-encode the result
- Compare the computed value to the header using a constant-time comparison function (Square warns that a byte-by-byte early-exit comparison exposes the key to timing analysis)
- Reject the request with an error status when the comparison fails — never fall through to processing
Step 4: Rotate and Contain
- Use a distinct signature key per environment so a sandbox leak cannot forge production events
- Rotate the key if it is ever exposed, and re-verify that the endpoint rejects unsigned traffic afterward
- To rotate on a schedule, call
UpdateWebhookSubscriptionSignatureKey(POST /v2/webhooks/subscriptions/{subscription_id}/signature-key), which issues a new key for all future notifications — Square’s own example rotates every 90 days (Webhook Subscriptions API)
Source: Validate a webhook event notification
Code Implementation
Two surfaces. The API pack inventories the application’s webhook subscriptions, including disabled ones, and flags any notification URL that is not HTTPS; it never prints a signature key. Because subscriptions belong to the application rather than to a seller, the Webhook Subscriptions API requires the application’s personal access token and rejects OAuth access tokens (Webhook Subscriptions API). The SDK pack is a receiver that checks every notification with the Square Node.js SDK’s WebhooksHelper.verifySignature (SDK 40.0.0 or later) over the raw request body and answers HTTP 403 when verification fails (Validate a webhook event notification).
Code Pack: API Script
audit() {
local cursor="" pages=0 subs total insecure disabled
: > "${ITEMS_FILE}"
while :; do
sq "${SQUARE_BASE}/webhooks/subscriptions?include_disabled=true&limit=100${cursor:+&cursor=$(jq -rn --arg c "${cursor}" '$c|@uri')}"
jq -c '.subscriptions[]? | {id, name, enabled, notification_url, api_version, event_count: ((.event_types // []) | length)}' \
"${BODY_FILE}" >> "${ITEMS_FILE}"
cursor=$(jq -r '.cursor // ""' "${BODY_FILE}")
pages=$((pages + 1))
[ -n "${cursor}" ] || break
if [ "${pages}" -ge 500 ]; then
echo "PRECONDITION: ListWebhookSubscriptions still returned a cursor after 500 pages — the inventory is incomplete, so no verdict is given." >&2
exit 2
fi
done
subs=$(jq -s '.' "${ITEMS_FILE}")
total=$(j "${subs}" 'length')
echo "Square 3.3 — webhook subscriptions for the token's application (TRAP 2)"
echo " subscriptions (including disabled): ${total}"
j "${subs}" -r '.[] | " - subscription=…\((.id // "unknown")[-6:]) enabled=\(.enabled // false) api_version=\(.api_version // "?") events=\(.event_count) url_scheme=\((.notification_url // "") | split(":")[0])"'
if [ "${total}" -eq 0 ]; then
echo "NO SUBSCRIPTIONS: this application receives no webhooks, so there is nothing to sign or verify."
return 0
fi
insecure=$(j "${subs}" '[.[] | select(((.notification_url // "") | startswith("https://")) | not)] | length')
disabled=$(j "${subs}" '[.[] | select((.enabled // false) | not)] | length')
if [ "${disabled}" -gt 0 ]; then
echo "REVIEW: ${disabled} disabled subscription(s) still hold a signature key — delete them if they are not coming back."
fi
if [ "${insecure}" -gt 0 ]; then
echo "FINDING: ${insecure} subscription(s) deliver to a non-HTTPS notification URL — the signed payload travels in clear text."
return 1
fi
echo "COMPLIANT: every subscription delivers over HTTPS. Verify the receiver separately (TRAP 4)."
return 0
}
Code Pack: SDK Script
import * as http from 'node:http';
import { WebhooksHelper } from 'square';
const SIGNATURE_KEY = process.env.SQUARE_WEBHOOK_SIGNATURE_KEY;
const NOTIFICATION_URL = process.env.SQUARE_WEBHOOK_NOTIFICATION_URL;
if (!SIGNATURE_KEY || !NOTIFICATION_URL) {
console.error('PRECONDITION: set SQUARE_WEBHOOK_SIGNATURE_KEY and SQUARE_WEBHOOK_NOTIFICATION_URL');
process.exit(2);
}
async function isFromSquare(signatureHeader, rawBody) {
if (!signatureHeader) return false;
return await WebhooksHelper.verifySignature({
requestBody: rawBody,
signatureHeader,
signatureKey: SIGNATURE_KEY,
notificationUrl: NOTIFICATION_URL,
});
}
http.createServer((req, res) => {
const chunks = [];
req.on('data', (chunk) => chunks.push(chunk));
req.on('end', async () => {
const rawBody = Buffer.concat(chunks).toString('utf8');
let verified = false;
try {
verified = await isFromSquare(req.headers['x-square-hmacsha256-signature'], rawBody);
} catch {
verified = false;
}
if (!verified) {
res.writeHead(403);
res.end();
return;
}
// Only now is rawBody trusted: hand it to your event processor here.
res.writeHead(200);
res.end();
});
}).listen(Number(process.env.PORT || 8000));
Validation & Testing
- Send a request to the endpoint with no signature header — it must be rejected
- Send a valid payload with a tampered body byte — it must be rejected
- Confirm from application logs that a rejected notification is not processed downstream (no order, payment, or customer record is written)
Compliance Mappings
| Framework | Control | How This Maps |
|---|---|---|
| CIS Controls v8 | 3.10 Encrypt Sensitive Data in Transit | Authenticated, TLS-delivered event payloads |
| NIST 800-53 Rev 5 | SI-7 Software, Firmware, and Information Integrity | Cryptographic verification of received data |
| NIST 800-53 Rev 5 | SC-8 Transmission Confidentiality and Integrity | Integrity check on inbound vendor traffic |
| PCI DSS v4.0 | 6.2.4 | Protects payment-event handling code from injected input |
4. Compliance Quick Reference
SOC 2 Trust Services Criteria Mapping
| Control ID | Square Control | Guide Section |
|---|---|---|
| CC6.1 | SSO/2FA | 1.1 |
| CC6.2 | Team permissions | 2.1 |
| CC6.7 | API security | 3.2 |
| CC6.6 | Webhook signature verification | 3.3 |
PCI DSS v4.0 Mapping
| Requirement | Square Control | Guide Section |
|---|---|---|
| 7 | Team permissions | 2.1 |
| 8 | Authentication | 1.2 |
Appendix A: References
Official Square Documentation:
- Help Center
- Privacy and Security Measures – describes Square’s account security measures
Help-center caveat (2026-08):
squareup.com/helpis a single-page application that returns HTTP 200 with the support homepage for article slugs that no longer exist, so a link there can appear live while pointing at nothing. Confirm the rendered article title matches the citation before trusting a help-center link.
API & Developer Tools:
- Square API Reference
- Square Developer Portal
- OAuth API overview – token and refresh-token lifetimes
- Access tokens – personal access token scope
- Validate a webhook event notification – signature verification
- SDKs available for multiple languages – via Developer Portal
Compliance Frameworks:
- Square publicly claims PCI DSS Level 1 (Service Provider) and ISO 27001 status. The pages that carried those claims are vendor marketing rather than configuration documentation and were removed from this appendix under the repo source standard; the certification status itself is unverified in this pass – request current attestation documents from Square directly.
Security Incidents:
- (2021-12) A former Block (Square) employee accessed Cash App Investing reports after employment ended, exposing full names, brokerage account numbers, and portfolio data for approximately 8.2 million current and former customers. Disclosed April 2022.
- (2023-09) Multi-hour system outage affected merchants; forensic analysis ruled out cyberattack – no data breach confirmed.
Changelog
| Date | Version | Maturity | Changes | Author |
|---|---|---|---|---|
| 2026-09-25 | 0.3.0 | ai-drafted | validate-hth-guide run (Phases 4–5, doc-level only): add four read-only API audit packs (2.2, 3.1, 3.2, 3.3) and an SDK webhook-verification pack (3.3), tested offline against fixtures and a fail-closed Sandbox run but not executed against a live Square account. An independent audit then corrected three packs: 3.1 now judges devices by their status category instead of updated_at, 2.2 treats zero active locations as a failed scan, and 3.2 treats a response that describes no token as a failed call. Also: add evidenced ClickOps-only Automation verdicts to 1.1, 1.2, 2.1 and 2.3; correct console paths in 1.2, 2.1, 3.1 and 3.3 and the Developer Console naming in 3.2/3.3 against current Square docs; add navigation to 2.2 and 2.3 and replace 2.3’s multi-owner guidance with Full-access limits; list every subscription tier in 2.1’s plan note; add the Full-access caveat to 2.1, the third-party App integrations review to 3.2, and Square’s documented signature-key rotation call to 3.3. A second independent audit then made 2.2, 3.1 and 3.3 fail closed on an HTTP 200 that carries an error on any page, made 2.2 list broad non-owners for review on single-location accounts, and made the API packs time out stalled requests, fail closed at their page cap, and read RFC 3339 offsets in 3.1 and 3.2 timestamps. No console was walked live (Square sign-in wall), so 0 surfaces are verified live and maturity is unchanged. |
Claude Code (Opus 5.5) |
| 2026-08-08 | 0.2.0 | ai-drafted | Currency pass: add 3.3 webhook signature verification; document OAuth/PAT token lifetimes and unrestricted-PAT scope in 3.2; annotate 1.1 SSO as externally unverifiable; prune marketing and rotted help-center references from Appendix A. Tier 3/4 sources not surveyed this pass. | Claude Code (Opus 5) |
| 2026-06-29 | 0.1.1 | ai-drafted | Add cheat-sheet Description and Rationale for all controls | Claude Code (Opus 4.8) |
| 2025-02-05 | 0.1.0 | ai-drafted | Initial guide with SSO and permissions | Claude Code (Opus 4.5) |
Contributing
Found an issue or want to improve this guide?
- Report outdated information: Open an issue with tag
content-outdated - Propose new controls: Open an issue with tag
new-control - Submit improvements: See Contributing Guide