Webhook Security¶
Webhooks are the primary attack surface — they accept requests from the public internet with no browser context, no cookies, and no CSRF tokens. Any HTTP client can send a POST to the webhook URL. 4pass implements 4 mandatory validation layers, each of which must pass before an order is executed. A failure at any layer terminates the request immediately.
Validation Flow¶
flowchart TB
REQ["Incoming Webhook Request"] --> L1{"Level 1<br/>URL Token"}
L1 -->|Invalid| R404["404"]
L1 -->|Valid| L2{"Level 2<br/>Timestamp"}
L2 -->|Expired| R401a["401"]
L2 -->|Valid| L3{"Level 3<br/>Secret"}
L3 -->|Wrong| R401b["401 + Alert"]
L3 -->|Valid| L4{"Level 4<br/>Replay"}
L4 -->|Duplicate| R409["409"]
L4 -->|Unique| EXEC["Order Execution"]
Each layer serves a distinct security purpose and returns a different HTTP status code, enabling precise attack classification in logs and monitoring.
Level 1: Unique Token per Account¶
The webhook URL itself contains an opaque token that identifies both the user and the specific trading account:
| Property | Detail |
|---|---|
| Token format | 32-byte URL-safe string (secrets.token_urlsafe(32)) |
| Entropy | 256 bits |
| Scope | Identifies both the user AND the specific trading account |
| Lookup | Token → TradingAccount → User (single DB query with join) |
| Invalid token | 404 Not Found — does not reveal whether the account exists |
| Disabled user | 403 Forbidden — account exists but user is deactivated |
Why 404 Instead of 401?
Returning 404 Not Found for invalid tokens prevents account enumeration. An attacker cannot distinguish between "this token doesn't exist" and "this URL path doesn't exist." A 401 would confirm the endpoint exists and invite further probing.
Level 2: Timestamp Validation¶
Every real-trading webhook request must include a timestamp. The server validates that the request was generated recently, preventing old or captured requests from being replayed hours or days later.
| Property | Detail |
|---|---|
| Payload field | "timestamp": "{{timenow}}" (TradingView built-in variable) |
| Format | UTC timestamp string, parsed server-side |
| Validation | |server_time - request_timestamp| < max_request_age |
| Default max age | 300 seconds (5 minutes) |
| Configurable | Per-account max_request_age setting |
| Failure | 401 Unauthorized + webhook_timestamp_expired audit event + email alert |
| Simulation mode | Bypassed — simulation orders don't require timestamps |
request_age = abs(time.time() - timestamp.timestamp())
if request_age > ctx.max_request_age:
raise HTTPException(status_code=401, detail="Request expired")
Clock Skew
The 300-second window accounts for network latency and minor clock differences between TradingView's servers and 4pass. The abs() also handles the case where TradingView's clock is slightly ahead. Users experiencing persistent timestamp failures can increase max_request_age in their account settings.
Level 3: Secret Verification¶
Each trading account has a unique webhook secret that must be included in the request body. This authenticates the sender — even if an attacker discovers the webhook URL (Level 1), they cannot execute orders without the secret.
| Property | Detail |
|---|---|
| Payload field | "secret": "your-webhook-secret-here" |
| Secret format | 32-byte URL-safe string (secrets.token_urlsafe(32)) |
| Entropy | 256 bits |
| Comparison | hmac.compare_digest(secret, ctx.webhook_secret) — constant-time |
| Failure | 401 Unauthorized + webhook_credential_attack audit event + email alert |
| Rotation | Users can regenerate their webhook secret from the dashboard at any time |
Why Constant-Time Comparison?
A naive secret == expected comparison leaks information through timing. If the first character matches, the comparison takes slightly longer than if it doesn't. An attacker can brute-force the secret one character at a time by measuring response times. hmac.compare_digest() compares all bytes in constant time regardless of where they differ.
Level 4: Replay Detection¶
Even with a valid token, timestamp, and secret, an attacker who captures a legitimate request could replay it within the timestamp window. Level 4 prevents this by tracking request fingerprints in Redis.
| Property | Detail |
|---|---|
| Hash input | user_id:action:symbol:quantity:timestamp:secret |
| Algorithm | SHA-256, truncated to 32 hex chars |
| Storage | Redis key: webhook:replay:{hash} |
| TTL | max_request_age seconds (matches timestamp window) |
| Duplicate check | redis.exists(key) — if key exists, request is a replay |
| Failure | 409 Conflict + webhook_replay_attack audit event + email alert |
flowchart TB
REQ["Webhook Request"] --> HASH["SHA-256 hash of:<br/>user_id:action:symbol:quantity:timestamp:secret"]
HASH --> CHECK{"Redis key<br/>exists?"}
CHECK -->|"Yes"| REJECT["409 Conflict<br/>Replay detected"]
CHECK -->|"No"| STORE["Store hash in Redis<br/>TTL = max_request_age"]
STORE --> PROCEED["Proceed to<br/>order execution"]
The hash includes the secret field to prevent an attacker from crafting a different request with the same hash. The TTL automatically expires old hashes, keeping Redis memory bounded.
Additional Protections¶
Beyond the 4-layer validation, webhook requests pass through additional checks before order execution:
| Protection | Implementation | Failure |
|---|---|---|
| Per-user rate limiting | 60 requests/minute via Redis sorted set sliding window | 429 Too Many Requests |
| IP whitelist | Optional per-user restriction (user_allowed_ips table) |
403 Forbidden + webhook_ip_blocked alert |
| Subscription check | Free plan users restricted to simulation only | 403 Forbidden |
| Daily order limit | Plan-based maximum orders per day (prevents runaway trading) | 429 Too Many Requests |
| Credential verification | Reject if broker credentials haven't been verified | 400 Bad Request |
| Terms of Service | Reject if user hasn't accepted current ToS | 403 Forbidden |
| Audit logging | All attempts (success and failure) logged with IP, User-Agent, full context | — |
Rate Limiting is Fail-Open
The per-user rate limiter uses Redis but fails open on Redis connection errors. This design ensures that a temporary Redis outage doesn't block legitimate trading operations. WAF-level rate limiting (per-IP, AWS-managed) provides a backstop.
TradingView Configuration¶
Webhook URL¶
Replace {account_webhook_token} with the token shown in your dashboard under Trading Accounts → Webhook Settings.
Alert Message (JSON Payload)¶
{
"action": "{{strategy.order.alert_message}}",
"symbol": "TXFR1",
"quantity": {{strategy.order.contracts}},
"timestamp": "{{timenow}}",
"secret": "your-webhook-secret-here"
}
| Field | Source | Description |
|---|---|---|
action |
{{strategy.order.alert_message}} |
TradingView strategy action (buy, sell, close, etc.) |
symbol |
Hardcoded or {{ticker}} |
The instrument to trade |
quantity |
{{strategy.order.contracts}} |
Number of contracts/lots |
timestamp |
{{timenow}} |
Current UTC time (TradingView built-in) |
secret |
Your webhook secret | Found in dashboard → Trading Accounts → Webhook Settings |
Simulation Mode
Append ?simulation=true to the webhook URL to execute in simulation mode. Simulation orders bypass timestamp and secret validation (Levels 2-3) and do not execute real trades.
Security Comparison¶
How 4pass webhook security compares to common alternatives:
| Feature | 4pass | Typical Webhook | API Key Only |
|---|---|---|---|
| URL-based account isolation | Yes Per-account token | No Shared URL | No |
| Timestamp validation | Yes Configurable window | No | No |
| Secret authentication | Yes Constant-time compare | No or basic auth | Yes Header |
| Replay detection | Yes Redis + SHA-256 | No | No |
| Per-user rate limiting | Yes 60/min sliding window | No | Per-key |
| IP whitelisting | Yes Optional per-user | No | No |
| Real-time attack alerts | Yes Email with context | No | No |
| Audit logging | Yes Full request context | No | Partial |