Draft: Feat(webhooks): add replay attack protection
What does this MR do?
Adds replay attack protection for webhooks. When enabled, GitLab includes cryptographic headers that allow receivers to verify that a webhook request is authentic, untampered, and fresh.
New headers
When replay protection is enabled on a webhook, GitLab adds two new headers to every request:
| Header | Purpose | Example |
|---|---|---|
X-Gitlab-Timestamp |
Unix timestamp when the request was created | 1738512345 |
X-Gitlab-Signature |
HMAC-SHA256 signature to verify authenticity | sha256=7f2e... |
Important security behavior
When replay protection is enabled, the X-Gitlab-Token header is no longer sent in plaintext. The signature alone proves the sender knows the secret token. This prevents an attacker who intercepts a request from extracting the token and forging new valid signatures.
Why is this needed?
The problem: Replay attacks
Without replay protection, an attacker who intercepts a valid webhook request can re-send it later to trigger unintended actions. The existing X-Gitlab-Token header doesn't help because:
- It's a static value that never changes between requests
- It's sent in plaintext in every request, so an attacker who intercepts one request has everything needed to replay it indefinitely
Example attack scenario (without protection):
- GitLab sends a webhook for a
merge_requestevent to trigger a deployment - An attacker intercepts this request (e.g., via compromised logs or network sniffing)
- Days later, the attacker re-sends the exact same request
- The receiver validates the
X-Gitlab-Token, which is still valid - The deployment is triggered again unexpectedly
The solution: Timestamp + Signature
The core idea is based on HMAC (Hash-based Message Authentication Code). Here's how it works:
How the signature is built
Both GitLab and the receiver share a secret token that is configured once during webhook setup (copy & paste into both systems). This token is never sent over the network when replay protection is enabled.
GitLab builds the signature like this:
Signature = HMAC-SHA256(
Key: secret token (known only to GitLab and the receiver)
Data: timestamp + "." + webhook_uuid + "." + request_body
)
Think of HMAC like a mixer: you put in the data (timestamp, UUID, body) together with a secret ingredient (the token). Only someone who knows the secret ingredient can produce the exact same result. An attacker can see the output (the signature) but cannot reverse-engineer the secret ingredient or produce a valid signature for modified data.
What this protects against
| Attack | Protected? | How |
|---|---|---|
| Replay after 5+ minutes |
|
Receiver rejects requests with stale timestamps |
| Forging a new timestamp |
|
Attacker can't compute a valid signature without the secret token |
| Tampering with the body |
|
Any change to the body invalidates the signature |
| Replay within 5 minutes |
|
Receiver should track X-Gitlab-Webhook-UUID to reject duplicates |
Same attack scenario WITH protection
- GitLab sends a webhook with
X-Gitlab-Timestamp: 1738512345andX-Gitlab-Signature: sha256=abc... - The
X-Gitlab-Tokenis not included in the request (the signature replaces it) - Attacker intercepts and replays the request days later
- Receiver checks:
current_time - 1738512345 > 300 seconds→ Request rejected as stale - Even if replayed within 5 minutes: receiver checks UUID against a seen-list → Duplicate rejected
- Attacker tries to change the timestamp to bypass the check → Signature becomes invalid (attacker doesn't know the secret token)
Feature availability
-
Project Hooks: UI + API
✅ -
Group Hooks: UI + API
✅ -
System Hooks: UI + API
✅
Database changes
- Adds
enable_replay_protectionboolean column toweb_hookstable (default:false)
Related issues
Closes #587536
Screenshots or screen recordings
| Before | After |
|---|---|
|
|
How to set up and validate locally
1. Create a webhook with replay protection enabled
Via UI
- Go to Project/Group Settings → Webhooks (or Admin → System Hooks)
- Enter a URL (e.g.,
https://webhook.site/your-idor any request catcher) - Set a Secret token (required for signature generation)
- Check Enable replay attack protection
- Save the webhook
Via API
The enable_replay_protection parameter works for all webhook types. Example for a project hook:
curl --request POST \
--header "PRIVATE-TOKEN: <your-token>" \
--header "Content-Type: application/json" \
--data '{
"url": "https://webhook.site/your-id",
"token": "your-secret-token",
"enable_replay_protection": true,
"push_events": true
}' \
"http://gdk.local:3000/api/v4/projects/<project-id>/hooks"
For group hooks, use /api/v4/groups/<group-path>/hooks. For system hooks, use /api/v4/hooks.
2. Trigger the webhook
Push to the repository, or trigger manually via Rails console:
hook = ProjectHook.find(<hook-id>)
hook.execute({ test: 'replay-protection' }, 'push_hooks')
3. Verify the headers
In your request catcher, confirm:
-
X-Gitlab-TimestampandX-Gitlab-Signatureare present -
X-Gitlab-Tokenis NOT present (this is expected when replay protection is enabled)
| Header | Example Value |
|---|---|
X-Gitlab-Timestamp |
1738512345 |
X-Gitlab-Signature |
sha256=82335a5d0b6028e1217a210c7612b153d8dc03abba18d3bf045666239459333b |
X-Gitlab-Webhook-UUID |
ea15f344-d99f-4e48-9096-220ab1631d99 |
4. Validate the signature
Verify the signature is correctly computed (Rails console):
timestamp = "<X-Gitlab-Timestamp>"
webhook_uuid = "<X-Gitlab-Webhook-UUID>"
body = '<request body JSON>'
token = "your-secret-token"
payload = "#{timestamp}.#{webhook_uuid}.#{body}"
expected = "sha256=#{OpenSSL::HMAC.hexdigest('sha256', token, payload)}"
puts "Expected: #{expected}"
puts "Received: <X-Gitlab-Signature>"
# These should match
5. Verify UI badge
Go to the webhooks list page. Webhooks with replay protection enabled should display a "Replay Protection: enabled" badge next to the SSL verification badge.
How receivers can verify signatures
# Ruby example
def verify_webhook(request, secret_token)
timestamp = request.headers['X-Gitlab-Timestamp']
webhook_uuid = request.headers['X-Gitlab-Webhook-UUID']
signature = request.headers['X-Gitlab-Signature']
body = request.body.read
# 1. Reject if timestamp is older than 5 minutes
return false if (Time.now.to_i - timestamp.to_i).abs > 300
# 2. Reject if UUID was already seen (prevents replay within 5 min window)
return false if already_processed?(webhook_uuid)
mark_as_processed(webhook_uuid)
# 3. Compute and compare signature
payload = "#{timestamp}.#{webhook_uuid}.#{body}"
expected = "sha256=#{OpenSSL::HMAC.hexdigest('sha256', secret_token, payload)}"
ActiveSupport::SecurityUtils.secure_compare(expected, signature)
end
MR acceptance checklist
- Tests added for new functionality
- Documentation added
- API support for all webhook types
- Feature works without feature flag (opt-in per webhook)
- Secret token (
X-Gitlab-Token) is not sent in plaintext when replay protection is enabled - Tests verify token is excluded when replay protection is active


