HMAC Authentication
HMAC (Hash-based Message Authentication Code) provides request-level authentication for M3 Forge API endpoints. Every signed request includes a cryptographic signature that proves the sender possesses a shared secret — without transmitting the secret itself.
M3 Forge uses HMAC-SHA256 signatures for both incoming API requests (clients calling M3 Forge) and outgoing webhook deliveries (M3 Forge calling your endpoints).
How It Works
Each HMAC-signed request includes four headers:
| Header | Description | Example |
|---|---|---|
X-Marie-Timestamp | Unix epoch seconds when the request was signed | 1711036800 |
X-Marie-Nonce | UUID v4 for replay prevention | 550e8400-e29b-41d4-a716-446655440000 |
X-Marie-Signature | HMAC-SHA256 hex digest prefixed with sha256= | sha256=a1b2c3d4... |
X-Marie-Key-Id | Public identifier for the signing key | msk_aBcDeFgHiJkLmNoP |
Signature Construction
The signature is computed over a canonical message assembled from five components joined by newline characters:
{timestamp}\n{nonce}\n{METHOD}\n{path_and_query}\n{body}| Component | Rule |
|---|---|
| timestamp | Same value as X-Marie-Timestamp header |
| nonce | Same value as X-Marie-Nonce header |
| METHOD | HTTP method, uppercased (GET, POST, PUT, DELETE) |
| path_and_query | Request path including query string, no scheme or host (e.g. /api/trpc/workflows.list?batch=1) |
| body | Raw request body bytes. Empty string "" for requests with no body. |
Only application/json request bodies are supported for HMAC authentication. Requests using multipart/form-data or application/x-www-form-urlencoded are not covered.
Replay Protection
M3 Forge applies two layers of replay protection:
- Timestamp window — Requests are rejected if the signature timestamp differs from server time by more than 60 seconds.
- Nonce deduplication — The server tracks recently seen
(keyId, nonce)pairs for 120 seconds. Duplicate nonces within this window are rejected. Since the nonce is part of the signed message, it cannot be stripped or altered without invalidating the signature.
Setup
Generate a signing key
Navigate to Settings → Signing Keys and click Generate Signing Key. Provide a name and select the scopes this key should have access to.
The secret is displayed only once after generation. Copy and store it securely — it cannot be retrieved later.
Store your credentials
Save both values in your application’s secret management system:
- Key ID (
msk_...) — Public identifier sent with every request. Safe to log. - Secret — 32-byte hex string used to compute signatures. Treat as a password.
Sign your requests
Include the four HMAC headers on every API request. See Signing Requests below for code examples in Python and TypeScript.
When HMAC is Applied
Incoming requests (clients → M3 Forge)
When a client sends a request to M3 Forge with the four HMAC headers present, the server verifies the signature before processing the request. HMAC authentication covers tRPC API routes (/api/trpc/*) where all business endpoints live.
REST routes (/api/auth/*, /api/oauth/*), SSE streams, and file uploads have their own authentication mechanisms and are not covered by HMAC.
Authentication precedence: Bearer token (JWT / mat_ API key) → Session cookie → HMAC signature. If a valid bearer token or session is already present, HMAC headers are ignored.
Outgoing requests (M3 Forge → your endpoints)
When you configure a trigger webhook with HMAC auth type, M3 Forge signs every outgoing delivery with the selected signing key. Your endpoint receives the four X-Marie-* headers and can verify the request originated from M3 Forge. See Verifying Signatures for receiver-side code.
Signing Requests
Use these examples to sign outgoing requests to M3 Forge from your application.
Python
import hashlib
import hmac
import time
import uuid
import requests
MARIE_KEY_ID = "msk_aBcDeFgHiJkLmNoP"
MARIE_SECRET = "your-secret-hex-string"
def sign_request(method: str, url: str, body: str = "") -> dict:
"""Generate HMAC signature headers for an M3 Forge API request."""
timestamp = str(int(time.time()))
nonce = str(uuid.uuid4())
# Build the canonical message
message = f"{timestamp}\n{nonce}\n{method.upper()}\n{url}\n{body}"
# Compute HMAC-SHA256
signature = hmac.new(
MARIE_SECRET.encode("utf-8"),
message.encode("utf-8"),
hashlib.sha256,
).hexdigest()
return {
"X-Marie-Timestamp": timestamp,
"X-Marie-Nonce": nonce,
"X-Marie-Signature": f"sha256={signature}",
"X-Marie-Key-Id": MARIE_KEY_ID,
}
# Example: List workflows
path = "/api/trpc/workflows.list?batch=1"
headers = sign_request("GET", path)
headers["Content-Type"] = "application/json"
response = requests.get(
f"https://your-instance.example.com{path}",
headers=headers,
)
print(response.json())Verifying Signatures
When M3 Forge sends HMAC-signed webhook deliveries to your endpoint, verify the signature before processing the payload.
Extract the headers
Read the four X-Marie-* headers from the incoming request.
Reconstruct the canonical message
Build the same "{timestamp}\n{nonce}\n{METHOD}\n{path_and_query}\n{body}" string using the request data and the timestamp/nonce from the headers.
Compare signatures
Compute HMAC-SHA256 over the message using your copy of the shared secret and compare with the received signature. Always use a timing-safe comparison to prevent timing attacks.
Python
import hashlib
import hmac
import time
SHARED_SECRET = "your-secret-hex-string"
MAX_AGE_SECONDS = 60
def verify_webhook(headers: dict, method: str, url: str, body: str) -> bool:
"""Verify an HMAC-signed request from M3 Forge."""
timestamp = headers.get("X-Marie-Timestamp", "")
nonce = headers.get("X-Marie-Nonce", "")
received_sig = headers.get("X-Marie-Signature", "")
# Check timestamp freshness
try:
ts = int(timestamp)
except ValueError:
return False
if abs(time.time() - ts) > MAX_AGE_SECONDS:
return False
# Strip the "sha256=" prefix
if not received_sig.startswith("sha256="):
return False
received_hex = received_sig[7:]
# Reconstruct and sign
message = f"{timestamp}\n{nonce}\n{method.upper()}\n{url}\n{body}"
expected_hex = hmac.new(
SHARED_SECRET.encode("utf-8"),
message.encode("utf-8"),
hashlib.sha256,
).hexdigest()
# Timing-safe comparison
return hmac.compare_digest(expected_hex, received_hex)Scope Model
HMAC signing keys carry an explicit list of allowed scopes. When a request authenticates via HMAC, the caller receives only the scopes assigned to the key — not the key owner’s full role permissions.
This means a leaked key is limited to its configured scopes. For example, a key with ["workflows:read", "runs:create"] can list workflows and trigger runs, but cannot modify workflows, access settings, or perform admin operations.
| Concept | Behavior |
|---|---|
| Scopes are additive | A key can have one or many scopes |
| No privilege escalation | HMAC keys never inherit admin access, even if the owner is an admin |
| Restricted scopes | user:impersonate, user:changeRole, settings:update, role:manage, and externalApp:manage cannot be assigned to HMAC keys |
Choose the minimum set of scopes your integration needs. You can always add more scopes later without rotating the secret.
Key Management
Rotating secrets
Navigate to Settings → Signing Keys, select a key, and click Rotate Secret. This generates a new secret while keeping the same Key ID (msk_...). The old secret is immediately invalidated.
Rotate secrets during a maintenance window. Any in-flight requests signed with the old secret will be rejected after rotation.
Disabling keys
Toggle a key’s Enabled state to temporarily suspend authentication without deleting the key. Disabled keys reject all incoming requests and are not used for outgoing webhook signing.
Deleting keys
Deleting a key permanently removes it. Any webhooks configured with this key will fall back to unsigned delivery. This action cannot be undone.
Security Best Practices
- Store secrets securely. Use a secrets manager (AWS Secrets Manager, HashiCorp Vault, environment variables) — never hard-code secrets in source code.
- Rotate regularly. Rotate signing keys on a schedule (e.g., every 90 days) and immediately if a secret may have been exposed.
- Use minimal scopes. Assign only the scopes your integration needs. Avoid broad scope sets.
- Monitor usage. Check the Last Used timestamp in Settings to identify inactive keys that should be disabled or deleted.
- Enforce HTTPS. HMAC protects against tampering, not eavesdropping. Always use HTTPS to prevent the signature headers and request body from being intercepted.
- Validate on your side. When receiving webhooks from M3 Forge, always verify the signature before processing. Reject requests with expired timestamps or invalid signatures.
Troubleshooting
401 Unauthorized — Signature verification failed
| Cause | Fix |
|---|---|
| Wrong secret | Verify you’re using the correct secret for the Key ID in the request |
| Body mismatch | Sign the raw request body exactly as transmitted. JSON serialization differences (key ordering, whitespace) will produce different signatures |
| URL mismatch | The URL component must be the path and query string only — no scheme (https://) or host. Example: /api/trpc/workflows.list?batch=1 |
| Method case | The HTTP method must be uppercased in the canonical message: GET, POST, not get, post |
| Timestamp expired | Signatures expire after 60 seconds. Check that the signing machine’s clock is synchronized (NTP) |
| Nonce reused | Each request must use a unique nonce (UUID v4). Do not reuse nonces from previous requests |
403 Forbidden — Insufficient scopes
The signing key does not have the required scope for the requested endpoint. Check the key’s allowed scopes in Settings → Signing Keys and add the missing scope.
401 Unauthorized — Key disabled or not found
Verify the key is enabled and the Key ID (X-Marie-Key-Id) matches an active key in your M3 Forge instance.
Clock drift issues
If your server’s clock is more than 60 seconds off from M3 Forge’s server time, all requests will be rejected. Use NTP to synchronize clocks:
# Check clock offset
ntpdate -q pool.ntp.org
# Sync (requires root)
sudo ntpdate pool.ntp.org