How Hermes Agent Implements DM Pairing and User Allowlist Security

Hermes Agent uses a two-layer authorization model combining cryptographically-secure DM pairing codes with configurable static allowlists to control gateway access across Telegram, Discord, WhatsApp, and other platforms.

The NousResearch/hermes-agent repository protects its messaging gateways through a defense-in-depth approach to DM pairing and user allowlist security. This system ensures that only explicitly authorized users can interact with the agent, whether through on-demand pairing workflows or pre-configured static lists.

The Two-Layer Security Model

Hermes Agent implements authorization through two distinct but complementary layers:

  1. Dynamic DM Pairing: A cryptographically secure, time-limited code system for on-demand user approval
  2. Static Allowlists: Environment-variable-based user ID lists for permanent access control

These layers operate sequentially in [gateway/run.py](https://github.com/NousResearch/hermes-agent/blob/main/gateway/run.py), where the _is_user_authorized method (around line 1060) first consults the pairing store before falling back to static allowlist checks.

Layer 1: DM Pairing Workflow

Code Generation and Rate Limiting

When an unauthorized user sends a direct message, the gateway invokes PairingStore.generate_code in gateway/pairing.py. This method creates an 8-character code using an unambiguous alphabet (excluding visually similar characters like '0' and 'O') through cryptographically secure random generation.

The system enforces security through multiple mechanisms:

  • Rate limiting: Prevents spamming of pairing requests
  • Maximum pending limits: Caps the number of unapproved codes per platform
  • 1-hour TTL: Codes automatically expire after 60 minutes
from gateway.pairing import PairingStore

store = PairingStore()
code = store.generate_code("telegram", "123456789", "Alice")
print(f"Generated pairing code: {code}")

# Output: AB6J9KLM (example)

Owner Approval Process

The gateway sends the pairing code to the user with instructions for the bot owner to approve it via CLI. The owner executes:

hermes pairing approve telegram AB6J9KLM

This command, implemented in [hermes_cli/pairing.py](https://github.com/NousResearch/hermes-agent/blob/main/hermes_cli/pairing.py), calls PairingStore.approve_code, which:

  1. Validates the code exists and hasn't expired
  2. Removes the pending entry
  3. Persists the user to {platform}-approved.json

# hermes_cli/pairing.py

store = PairingStore()
approved = store.approve_code("telegram", "AB6J9KLM")

# Returns: (user_id, user_name) tuple

Persistent Storage

All pairing data resides in ~/.hermes/pairing/ with strict 0600 file permissions (read/write for owner only). The _secure_write method ensures sensitive user data remains private on disk. Approved users are stored in platform-specific JSON files (e.g., telegram-approved.json), while pending codes track expiration timestamps and metadata.

Layer 2: Static Allowlist Configuration

Per-Platform Allowlists

Beyond dynamic pairing, Hermes Agent supports static allowlists through environment variables defined in [gateway/config.py](https://github.com/NousResearch/hermes-agent/blob/main/gateway/config.py). Each platform accepts a comma-separated list of user IDs:

  • TELEGRAM_ALLOWED_USERS
  • DISCORD_ALLOWED_USERS
  • WHATSAPP_ALLOWED_USERS
  • SLACK_ALLOWED_USERS
  • SIGNAL_ALLOWED_USERS

For WhatsApp specifically, the system normalizes JIDs by stripping the @s.whatsapp.net suffix before comparison.

Global Overrides

The authorization logic in _is_user_authorized supports hierarchical overrides:

  1. Per-platform allow-all flags: TELEGRAM_ALLOW_ALL_USERS, DISCORD_ALLOW_ALL_USERS, etc.
  2. Global allowlist: GATEWAY_ALLOWED_USERS (applies across all platforms)
  3. Global allow-all flag: GATEWAY_ALLOW_ALL_USERS

If any allow-all flag is set to a truthy value, authorization bypasses all list checks. Otherwise, the system evaluates the union of per-platform and global allowlists.


# Example environment configuration

export TELEGRAM_ALLOWED_USERS="111111,222222"
export GATEWAY_ALLOWED_USERS="333333"
export GATEWAY_ALLOW_ALL_USERS="false"

Authorization Flow in Practice

When a message arrives at the gateway, the _handle_message method (around line 1060 in gateway/run.py) orchestrates the security checks in strict sequence:

  1. Pairing check: Query PairingStore.is_approved(platform, user_id) – if True, accept immediately
  2. Allowlist evaluation: If not paired, check environment-based allowlists and allow-all flags
  3. Unauthorized handling: If all checks fail, either send a pairing code (DMs) or silently drop (group chats)

# Simplified authorization logic from gateway/run.py

def _is_user_authorized(self, source) -> bool:
    # Layer 1: Check pairing store

    if self.pairing_store.is_approved(source.platform, source.user_id):
        return True
    
    # Layer 2: Check static allowlists

    if self._check_allow_all_flags(source.platform):
        return True
    
    return self._check_user_in_allowlists(source.user_id, source.platform)

CLI Management Commands

Hermes Agent provides command-line tools for managing the pairing system without restarting the gateway.

Approving a pending user:

hermes pairing approve telegram AB6J9KLM

This validates the code, moves the user from pending to approved status, and persists the change to ~/.hermes/pairing/telegram-approved.json.

Revoking access:

hermes pairing revoke telegram 123456789

Or programmatically:

from gateway.pairing import PairingStore

store = PairingStore()
store.revoke("telegram", "123456789")

# User must request a new pairing code to regain access

Security Considerations

The pairing system implements several defense mechanisms:

  • Cryptographic randomness: Uses secrets.choice with an unambiguous alphabet to prevent guessing attacks
  • Time-bound exposure: 1-hour TTL on pending codes limits the window for unauthorized approval
  • Rate limiting: Prevents brute-force attempts to guess valid codes
  • Filesystem security: 0600 permissions on all pairing files prevent other system users from reading approved user lists
  • Normalization: WhatsApp JIDs are normalized to prevent bypasses using different JID formats

Summary

  • Hermes Agent implements DM pairing and user allowlist security through a two-layer authorization model in gateway/run.py
  • Dynamic pairing in gateway/pairing.py generates cryptographically secure 8-character codes with 1-hour TTL, rate limiting, and secure file storage (0600 permissions)
  • Static allowlists support per-platform (TELEGRAM_ALLOWED_USERS) and global (GATEWAY_ALLOWED_USERS) configurations with override flags
  • The authorization flow checks pairing status first, then falls back to environment-based allowlists, rejecting unauthorized users with pairing prompts or silent drops
  • CLI commands hermes pairing approve and hermes pairing revoke manage access without service restarts

Frequently Asked Questions

How does the pairing code generation prevent guessing attacks?

The PairingStore.generate_code method in gateway/pairing.py uses Python's secrets module with a 32-character unambiguous alphabet (excluding visually similar characters like '0', 'O', '1', and 'I'). This produces 8-character codes with approximately 40 bits of entropy, making brute-force guessing computationally infeasible within the 1-hour TTL window.

Can I use static allowlists without enabling the pairing system?

Yes. If you populate environment variables like TELEGRAM_ALLOWED_USERS or GATEWAY_ALLOWED_USERS with comma-separated user IDs, the gateway will authorize those users immediately without requiring pairing codes. Set GATEWAY_ALLOW_ALL_USERS=true to bypass all restrictions entirely, though this is not recommended for production deployments.

What happens when a paired user is revoked?

When you run hermes pairing revoke <platform> <user_id> or call PairingStore.revoke(), the user ID is removed from the {platform}-approved.json file in ~/.hermes/pairing/. Subsequent messages from that user will fail the PairingStore.is_authorized check, triggering the gateway to either send a new pairing code (in DMs) or silently drop the message (in group chats), effectively requiring the user to re-authenticate.

How does WhatsApp authorization handle different JID formats?

The authorization logic in gateway/run.py::_is_user_authorized normalizes WhatsApp JIDs by stripping the @s.whatsapp.net suffix before comparing against allowlists or approved user lists. This ensures that a user ID like 123456789@s.whatsapp.net matches an entry of 123456789 in WHATSAPP_ALLOWED_USERS or the pairing store, preventing authorization bypasses through JID formatting variations.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →