Webhook Abuse in Flask with Bearer Tokens
Webhook Abuse in Flask with Bearer Tokens — how this specific combination creates or exposes the vulnerability
Webhook abuse in Flask APIs that rely on Bearer tokens occurs when an attacker can trigger or intercept webhook deliveries without valid authentication. If a Flask endpoint that receives webhook events validates only the presence of a Bearer token but does not verify the token’s scope, issuer, or binding to the intended resource, an attacker may be able to replay captured tokens or use stolen tokens to call the endpoint directly.
Consider a Flask route that processes Stripe webhooks:
@app.route('/webhook/stripe', methods=['POST'])
def stripe_webhook():
auth = request.headers.get('Authorization')
if not auth or not auth.startswith('Bearer '):
return 'Unauthorized', 401
token = auth.split(' ')[1]
# Only presence check, no validation of token audience/issuer
if token != os.getenv('EXPECTED_BEARER'):
return 'Forbidden', 403
data = request.json
# Process event
return 'ok', 200
In this pattern, if the EXPECTED_BEARER value is leaked (for example, via logs, source code exposure, or a compromised CI/CD variable), an attacker can send crafted POST requests to the webhook endpoint with that token and potentially cause duplicate events, data manipulation, or denial-of-service. Additionally, if the token is intended for a different audience (for example, a resource other than the webhook receiver), the lack of audience (aud) validation means the request is accepted even though it was not intended for this endpoint.
Another common vector is when tokens are issued with broad scopes and long lifetimes. If a token with write permissions is compromised, an attacker can repeatedly call the webhook receiver to spam events or trigger downstream actions that depend on webhook notifications, such as order fulfillment or notifications. Without additional context like an inbound IP check or per-request nonce, Flask cannot distinguish legitimate webhook deliveries from abusive ones.
Because webhooks are typically called by external services, rotating or hiding the token can be difficult. If the token is embedded in provider configurations, revoking and rotating it may require coordination with the webhook sender. During rotation, misconfigured endpoints may accept old tokens if Flask does not validate token metadata, leading to a window where both old and new tokens are accepted.
To summarize, the combination of Flask route handling, Bearer token usage, and insufficient validation of token metadata (audience, issuer, scope, binding) creates a path for webhook abuse. Attackers can replay tokens, escalate impact by triggering sensitive actions, or disrupt workflows by flooding the endpoint, especially when token lifecycle and rotation practices are weak.
Bearer Tokens-Specific Remediation in Flask — concrete code fixes
Remediation centers on strict validation of Bearer tokens and moving sensitive configuration out of code. Instead of a simple string comparison, validate token metadata and scope, and use environment-based configuration with strong secret management.
First, avoid hardcoding tokens. Use environment variables and ensure they are loaded securely:
import os
from dotenv import load_dotenv
load_dotenv()
EXPECTED_AUDIENCE = os.getenv('EXPECTED_AUDIENCE')
EXPECTED_ISSUER = os.getenv('EXPECTED_ISSUER')
REQUIRED_SCOPE = os.getenv('REQUIRED_SCOPE', 'webhook.write')
Next, implement validation that checks audience, issuer, and scope. If you use JWTs, decode and validate claims instead of comparing raw strings:
@app.route('/webhook/stripe', methods=['POST'])
def stripe_webhook():
auth = request.headers.get('Authorization')
if not auth or not auth.startswith('Bearer '):
return 'Unauthorized', 401
token = auth.split(' ')[1]
try:
# Example using PyJWT; adjust for your token format
import jwt
decoded = jwt.decode(token, options={'verify_signature': False})
if decoded.get('aud') != EXPECTED_AUDIENCE:
return 'Forbidden', 403
if decoded.get('iss') != EXPECTED_ISSUER:
return 'Forbidden', 403
scopes = decoded.get('scope', '').split()
if 'webhook.write' not in scopes and REQUIRED_SCOPE not in scopes:
return 'Forbidden', 403
except Exception:
return 'Unauthorized', 401
data = request.json
# Process event securely
return 'ok', 200
If tokens are opaque reference tokens rather than JWTs, use an introspection endpoint or a secure lookup service to validate them at runtime. Do not rely on static comparisons:
@app.route('/webhook/stripe', methods=['POST'])
def stripe_webhook():
auth = request.headers.get('Authorization')
if not auth or not auth.startswith('Bearer '):
return 'Unauthorized', 401
token = auth.split(' ')[1]
valid = validate_token_introspection(token)
if not valid:
return 'Forbidden', 403
data = request.json
return 'ok', 200
def validate_token_introspection(token):
# Call your identity provider's introspection endpoint
# Verify active=True, expected audience/issuer, and required scopes
return True # simplified
Additionally, bind tokens to the expected resource by checking a custom claim or a pre-shared identifier that ties the token to this specific webhook configuration. Enforce short lifetimes and rotate credentials regularly via your identity provider, and ensure Flask does not log full Authorization headers to avoid accidental leakage.