Skip to content

Safety Numbers & Identity Management

This guide covers Signal’s identity/safety number system and the bot’s tools for managing user identities, detecting abandoned accounts, and maintaining community hygiene.

Signal uses end-to-end encryption where each user has a unique cryptographic identity. When you communicate with someone, Signal generates a “safety number” - a fingerprint of both parties’ identity keys that can be verified out-of-band to confirm you’re talking to the right person.

A user’s safety number changes when they:

  • Reinstall Signal on the same device
  • Switch to a new device
  • Re-register their phone number
  • Delete and recreate their Signal account

When a safety number changes, Signal shows a notification: “Safety number changed”. You have two options:

  1. Verify - Confirm with the person out-of-band and mark as verified
  2. Trust - Accept the new identity without verification

For community bots managing thousands of users, manual verification isn’t practical.

Automated Safety Number Detection (Identity Polling)

Section titled “Automated Safety Number Detection (Identity Polling)”

The bot proactively detects safety number changes by polling signal-cli’s identity store every 5 minutes. This catches changes that would otherwise be silently auto-trusted by signal-cli.

Every 5 minutes:
1. Call listIdentities via JSON-RPC (returns ~2300 identities)
2. Compare each fingerprint against stored value in database
3. If fingerprint changed AND user is in a community group:
→ Trigger safety number verification flow
→ Add user to Entry/INDOC
→ Create 24h verification request
→ Notify admins in Actions chat

Signal-cli starts with --trust-new-identities always which silently auto-trusts new identity keys without throwing UntrustedIdentityException. This means the exception-based detection misses many changes. The polling approach closes this gap.

On first startup (or after a restart), the bot:

  1. Seeds all current fingerprints into the database (no alerts)
  2. Subsequent polls only trigger on actual fingerprint changes

This prevents false positives where every identity appears “new” after a restart.

TablePurpose
known_identity_fingerprintsStores UUID → fingerprint mapping for 2300+ identities
identity_change_logAudit trail of all detected fingerprint changes

Check recent identity changes:

-- Recent changes
SELECT uuid, display_name, action_taken, detected_at
FROM identity_change_log
ORDER BY detected_at DESC LIMIT 10;
-- Users with most changes
SELECT uuid, display_name, change_count, fingerprint_changed_at
FROM known_identity_fingerprints
WHERE change_count > 0
ORDER BY change_count DESC;

Look for [IDENTITY_POLL] in bot logs:

Terminal window
docker logs signal-bot-selfhosted-signal-bot-1 2>&1 | grep IDENTITY_POLL

The signal-cli trust command accepts a user’s current identity keys:

await sendRequest('trust', {
recipient: 'uuid-here',
trustAllKnownKeys: true
});

Important Limitation: The trust command succeeds even for unregistered accounts. It only verifies that identity keys exist in signal-cli’s local store, not that the account is currently active on Signal.

Users who:

  • Passed trust check (identity keys exist)
  • But show “This person isn’t using Signal” in the app

These are abandoned accounts - the user deleted Signal or re-registered with a different number, but the old identity keys remain cached.

We discovered that sendReceipt reveals the true registration status:

// Returns SUCCESS for active users
// Returns UNREGISTERED_FAILURE for abandoned accounts
const result = await sendRequest('sendReceipt', {
recipient: uuid,
type: 'read',
targetTimestamp: [Date.now()]
});

Response types:

  • SUCCESS - User is registered and active
  • UNREGISTERED_FAILURE - Account no longer exists on Signal

Located at /app/scripts/find-unregistered-users.js

Comprehensive tool for detecting and managing abandoned accounts.

Terminal window
# Scan all users and save results (cached for later operations)
node find-unregistered-users.js --scan --limit 3000
# List cached scan results
node find-unregistered-users.js --list
# Remove unregistered users from all groups (uses cached results)
node find-unregistered-users.js --remove
# Add unregistered users to Entry/INDOC for moderator review
node find-unregistered-users.js --to-indoc
# Add unregistered users to custom verification group
node find-unregistered-users.js --add-to-group "group-id-here"
# Scan and remove in one command
node find-unregistered-users.js --scan --remove
  1. Scan Phase: Queries database for users with active group memberships
  2. Check Phase: Sends read receipt to each user, checking for UNREGISTERED_FAILURE
  3. Cache Phase: Saves results to /app/data/unregistered-users-YYYY-MM-DD-HHMM.json
  4. Action Phase: Can remove users from groups or add to verification group
{
"scan_date": "2025-12-24T17:38:50.948Z",
"total_checked": 2144,
"total_unregistered": 13,
"unregistered_users": [
{
"uuid": "d6292870-2d4f-43a1-89fe-d63791ca104d",
"display_name": null,
"profile_name": null,
"group_count": 30,
"reason": "UNREGISTERED_FAILURE"
}
]
}

Bulk trust all known users to accept their identity keys:

Terminal window
node trust-all-users.js
node trust-all-users.js --create-group # Add failed users to Entry/INDOC

Note: This catches users whose identity keys are invalid, but misses unregistered accounts. Use find-unregistered-users.js for complete detection.

Remove users who fail the trust check from all groups:

Terminal window
node remove-failed-users.js --limit 5 # Test with 5 users
node remove-failed-users.js --limit 100 # Remove up to 100

List users who fail the trust check with their group counts:

Terminal window
node list-failed-users.js
Terminal window
# 1. Trust all users (catches invalid keys)
node trust-all-users.js --create-group
# 2. Find truly abandoned accounts
node find-unregistered-users.js --scan --limit 5000
# 3. Review results
node find-unregistered-users.js --list
# 4. Remove abandoned accounts
node find-unregistered-users.js --remove

Run monthly or quarterly:

Terminal window
# Quick scan of active users
node find-unregistered-users.js --scan --remove

For manual review before removal:

Terminal window
# 1. Scan and add to Entry/INDOC for moderator review
node find-unregistered-users.js --scan
node find-unregistered-users.js --to-indoc
# 2. Review users in Signal (check if they respond)
# 3. Remove confirmed abandoned accounts
node find-unregistered-users.js --remove

Signal’s architecture:

  1. Identity Store: signal-cli caches identity keys locally
  2. Registration Service: Signal servers track active registrations
  3. Trust Command: Only checks local identity store
  4. Send Operations: Check actual registration status

When a user deletes their account:

  • Their identity keys remain in signal-cli’s cache
  • Trust command succeeds (keys exist)
  • Send operations fail with UNREGISTERED_FAILURE

The scripts include delays to avoid overwhelming signal-cli:

  • 50ms between registration checks
  • 100ms between group operations

For large communities (2000+ users), expect full scans to take 3-5 minutes.

When removing users, the scripts:

  1. Call updateGroup with removeMember to remove from Signal group
  2. Update signal_member_group_memberships.is_active = false in database

This keeps the database in sync with actual group state.

Run a scan first:

Terminal window
node find-unregistered-users.js --scan

Some edge cases:

  • User just deleted account (may take time to propagate)
  • Network issues during check
  • Rate limiting from Signal servers

Re-run the scan to double-check.

Ensure scripts are owned by the node user:

Terminal window
docker exec -u root signal-bot chown node:node /app/scripts/*.js