[{"data":1,"prerenderedAt":1442},["ShallowReactive",2],{"content-query-ewW4XOfBF0":3},{"_path":4,"_dir":5,"_draft":6,"_partial":6,"_locale":7,"title":8,"description":9,"layout":10,"head":11,"author":12,"tags":13,"categories":19,"date":20,"faq":21,"excerpt":43,"body":60,"_type":1435,"_id":1436,"_source":1437,"_file":1438,"_stem":1439,"_extension":1440,"sitemap":1441,"readingTime":428},"/blog/kestra-security","blog",false,"","Kestra Security: CVE-2026-49869 and Webhooks Without Exposure","Kestra security guide for CVE-2026-49869: trigger Kestra flows from webhooks without exposing the API or UI, using an outbound-only Webhook Relay agent.","post",{"title":8},"Karolis Rusenas",[14,15,16,17,18],"kestra","security","cve","orchestration","webhooks",[15],"2026-10-28 14:00:00",[22,25,28,31,34,37,40],{"q":23,"a":24},"How do I trigger Kestra flows from webhooks without exposing Kestra?","Run a Webhook Relay agent next to Kestra and point the provider at the relay's public input URL. The agent connects outbound and forwards each delivery to the flow's private webhook URL, so Kestra needs no public IP, no port forward and no inbound firewall rule, and its UI and API are never reachable from the internet.",{"q":26,"a":27},"What is CVE-2026-49869?","A CVSS 10 authentication bypass in Kestra OSS. The authentication filter exempted any path ending in /configs, so an unauthenticated attacker could create and run a flow as root in the worker container. It affects versions up to 1.3.20, is fixed in 1.3.21 and 1.0.45, and is in the CISA Known Exploited Vulnerabilities catalog.",{"q":29,"a":30},"What is the Kestra webhook URL?","The Kestra documentation gives the webhook trigger URL as /api/v1/main/executions/webhook/{namespace}/{flowId}/{key} on your Kestra host. The key is set on the Webhook trigger of the flow and is the only check on the request; no Kestra login is required.",{"q":32,"a":33},"Is the Kestra webhook trigger authenticated?","Only by the secret key in the URL. Kestra's documentation states that anyone who has the webhook URL can call it without signing in. Use a long random key stored in Kestra Secrets, and add signature checks and filtering in front of it.",{"q":35,"a":36},"Does patching Kestra make it safe to expose to the internet?","Patching closes this bug but not the next one. A public Kestra still exposes the login page, the whole API and every plugin endpoint. If the only reason it is public is webhooks, forwarding just the webhook path through an outbound relay removes that exposure.",{"q":38,"a":39},"Does a relay fix CVE-2026-49869?","No. Upgrade to 1.3.21 or 1.0.45 or later. The relay only stops internet clients from reaching the vulnerable routes. The advisory notes that exploitation needs network access rather than an internet-facing instance, so internal networks still need the patch.",{"q":41,"a":42},"When does a relay not help with Kestra security?","When the webhook path itself is vulnerable to content in a genuine, signed delivery (signature checks at the relay stop unsigned requests), when users need the Kestra UI from outside your network (use a VPN or identity-aware proxy for that), or when workers connect from outside and need the API.",{"type":44,"children":45},"root",[46],{"type":47,"tag":48,"props":49,"children":50},"element","p",{},[51,58],{"type":47,"tag":52,"props":53,"children":54},"strong",{},[55],{"type":56,"value":57},"text","To trigger Kestra flows from webhooks without exposing Kestra, run a Webhook Relay agent beside it and give the provider the relay's public input URL.",{"type":56,"value":59}," The agent connects outbound and forwards each delivery to the flow's private webhook URL, so Kestra's UI and API are never on the internet. That matters because CVE-2026-49869, a CVSS 10 authentication bypass that is being exploited in the wild, let an unauthenticated attacker run commands as root through the API.",{"type":44,"children":61,"toc":1426},[62,70,77,164,193,198,204,209,214,227,233,245,264,293,482,495,545,550,556,577,595,707,712,753,780,840,877,1066,1079,1096,1113,1125,1131,1248,1260,1266,1271,1321,1327,1406,1420],{"type":47,"tag":48,"props":63,"children":64},{},[65,69],{"type":47,"tag":52,"props":66,"children":67},{},[68],{"type":56,"value":57},{"type":56,"value":59},{"type":47,"tag":71,"props":72,"children":74},"h2",{"id":73},"the-incident",[75],{"type":56,"value":76},"The incident",{"type":47,"tag":78,"props":79,"children":80},"table",{},[81,115],{"type":47,"tag":82,"props":83,"children":84},"thead",{},[85],{"type":47,"tag":86,"props":87,"children":88},"tr",{},[89,95,100,105,110],{"type":47,"tag":90,"props":91,"children":92},"th",{},[93],{"type":56,"value":94},"CVE",{"type":47,"tag":90,"props":96,"children":97},{},[98],{"type":56,"value":99},"What an attacker gets",{"type":47,"tag":90,"props":101,"children":102},{},[103],{"type":56,"value":104},"Auth needed",{"type":47,"tag":90,"props":106,"children":107},{},[108],{"type":56,"value":109},"Exploited in the wild",{"type":47,"tag":90,"props":111,"children":112},{},[113],{"type":56,"value":114},"Fixed in",{"type":47,"tag":116,"props":117,"children":118},"tbody",{},[119],{"type":47,"tag":86,"props":120,"children":121},{},[122,144,149,154,159],{"type":47,"tag":123,"props":124,"children":125},"td",{},[126],{"type":47,"tag":127,"props":128,"children":132},"a",{"href":129,"rel":130},"https://helix.ml/threat-watch/i/kestra-kestra-oss-cve-2026-49869",[131],"nofollow",[133,135,142],{"type":56,"value":134},"CVE-2026-49869, the Kestra ",{"type":47,"tag":136,"props":137,"children":139},"code",{"className":138},[],[140],{"type":56,"value":141},"/configs",{"type":56,"value":143}," auth bypass",{"type":47,"tag":123,"props":145,"children":146},{},[147],{"type":56,"value":148},"Create and run a flow, commands as root in the worker container, SSRF to cloud metadata addresses",{"type":47,"tag":123,"props":150,"children":151},{},[152],{"type":56,"value":153},"None",{"type":47,"tag":123,"props":155,"children":156},{},[157],{"type":56,"value":158},"Yes, added to CISA KEV on 2026-09-02",{"type":47,"tag":123,"props":160,"children":161},{},[162],{"type":56,"value":163},"1.3.21 and 1.0.45",{"type":47,"tag":48,"props":165,"children":166},{},[167,169,175,177,182,184,191],{"type":56,"value":168},"Kestra's authentication filter exempted the public configuration endpoint with a path-suffix check, so any API path whose last segment was ",{"type":47,"tag":136,"props":170,"children":172},{"className":171},[],[173],{"type":56,"value":174},"configs",{"type":56,"value":176}," skipped authentication. An attacker could create a flow named ",{"type":47,"tag":136,"props":178,"children":180},{"className":179},[],[181],{"type":56,"value":174},{"type":56,"value":183}," with a shell or Python script task, run it, and read the output from the logs. Versions up to 1.3.20 are affected. Kestra released the fix in 1.3.21 on 2026-06-02 and published the ",{"type":47,"tag":127,"props":185,"children":188},{"href":186,"rel":187},"https://github.com/kestra-io/kestra/security/advisories/GHSA-5vc5-wxxq-3fjx",[131],[189],{"type":56,"value":190},"advisory GHSA-5vc5-wxxq-3fjx",{"type":56,"value":192}," with a working proof of concept the next day. The Threat Watch record notes that CISA gives no victim or method details and lists ransomware use as unknown.",{"type":47,"tag":48,"props":194,"children":195},{},[196],{"type":56,"value":197},"The vulnerable routes are the flow-creation and execution routes. The webhook trigger path is not involved.",{"type":47,"tag":71,"props":199,"children":201},{"id":200},"why-exposure-is-the-multiplier",[202],{"type":56,"value":203},"Why exposure is the multiplier",{"type":47,"tag":48,"props":205,"children":206},{},[207],{"type":56,"value":208},"Kestra is a workflow orchestrator. It holds secrets, cloud credentials and the ability to run code, which is the point of the product. A bypass of the API's authentication therefore lands on a machine that can already reach your databases and cloud accounts.",{"type":47,"tag":48,"props":210,"children":211},{},[212],{"type":56,"value":213},"The advisory states that instances using the default basic-auth mode are affected whether or not they face the internet, so the patch is not optional on internal networks either. But the internet-facing instances are the ones any scanner can reach, and a public proof of concept lowers the work to a single request.",{"type":47,"tag":48,"props":215,"children":216},{},[217,219,225],{"type":56,"value":218},"A common reason to put Kestra on the internet is that a GitHub, Stripe or SaaS webhook has to reach a flow. The provider needs one path. Exposing Kestra directly gives it the login page, the whole REST API and every plugin endpoint as well. (The same trade-off, for CI servers, is in ",{"type":47,"tag":127,"props":220,"children":222},{"href":221},"/blog/securing-jenkins/",[223],{"type":56,"value":224},"securing Jenkins",{"type":56,"value":226},".)",{"type":47,"tag":71,"props":228,"children":230},{"id":229},"kestra-webhook-trigger-what-to-expose-and-what-not-to",[231],{"type":56,"value":232},"Kestra webhook trigger: what to expose and what not to",{"type":47,"tag":48,"props":234,"children":235},{},[236,243],{"type":47,"tag":127,"props":237,"children":240},{"href":238,"rel":239},"https://kestra.io/docs/workflow-components/triggers/webhook-trigger",[131],[241],{"type":56,"value":242},"Kestra's webhook trigger documentation",{"type":56,"value":244}," gives the URL as:",{"type":47,"tag":246,"props":247,"children":250},"pre",{"className":248,"code":249,"language":56,"meta":7,"style":7},"language-text shiki shiki-themes github-dark","https://{kestra_domain}/api/v1/main/executions/webhook/{namespace}/{flowId}/{key}\n",[251],{"type":47,"tag":136,"props":252,"children":253},{"__ignoreMap":7},[254],{"type":47,"tag":255,"props":256,"children":259},"span",{"class":257,"line":258},"line",1,[260],{"type":47,"tag":255,"props":261,"children":262},{},[263],{"type":56,"value":249},{"type":47,"tag":48,"props":265,"children":266},{},[267,269,275,277,283,285,291],{"type":56,"value":268},"The key is set on the trigger, and the page says anyone with the URL can call it without signing in, so the key is the only check. It supports ",{"type":47,"tag":136,"props":270,"children":272},{"className":271},[],[273],{"type":56,"value":274},"GET",{"type":56,"value":276},", ",{"type":47,"tag":136,"props":278,"children":280},{"className":279},[],[281],{"type":56,"value":282},"POST",{"type":56,"value":284}," and ",{"type":47,"tag":136,"props":286,"children":288},{"className":287},[],[289],{"type":56,"value":290},"PUT",{"type":56,"value":292},". A minimal flow, with the key read from Kestra Secrets as the docs recommend:",{"type":47,"tag":246,"props":294,"children":298},{"className":295,"code":296,"language":297,"meta":7,"style":7},"language-yaml shiki shiki-themes github-dark","id: webhook_example\nnamespace: company.team\ntasks:\n  - id: hello\n    type: io.kestra.plugin.core.log.Log\n    message: \"Hello from the webhook\"\ntriggers:\n  - id: webhook\n    type: io.kestra.plugin.core.trigger.Webhook\n    key: \"{{ secret('WEBHOOK_KEY') }}\"\n","yaml",[299],{"type":47,"tag":136,"props":300,"children":301},{"__ignoreMap":7},[302,323,341,355,377,395,413,426,447,464],{"type":47,"tag":255,"props":303,"children":304},{"class":257,"line":258},[305,311,317],{"type":47,"tag":255,"props":306,"children":308},{"style":307},"--shiki-default:#85E89D",[309],{"type":56,"value":310},"id",{"type":47,"tag":255,"props":312,"children":314},{"style":313},"--shiki-default:#E1E4E8",[315],{"type":56,"value":316},": ",{"type":47,"tag":255,"props":318,"children":320},{"style":319},"--shiki-default:#9ECBFF",[321],{"type":56,"value":322},"webhook_example\n",{"type":47,"tag":255,"props":324,"children":326},{"class":257,"line":325},2,[327,332,336],{"type":47,"tag":255,"props":328,"children":329},{"style":307},[330],{"type":56,"value":331},"namespace",{"type":47,"tag":255,"props":333,"children":334},{"style":313},[335],{"type":56,"value":316},{"type":47,"tag":255,"props":337,"children":338},{"style":319},[339],{"type":56,"value":340},"company.team\n",{"type":47,"tag":255,"props":342,"children":344},{"class":257,"line":343},3,[345,350],{"type":47,"tag":255,"props":346,"children":347},{"style":307},[348],{"type":56,"value":349},"tasks",{"type":47,"tag":255,"props":351,"children":352},{"style":313},[353],{"type":56,"value":354},":\n",{"type":47,"tag":255,"props":356,"children":358},{"class":257,"line":357},4,[359,364,368,372],{"type":47,"tag":255,"props":360,"children":361},{"style":313},[362],{"type":56,"value":363},"  - ",{"type":47,"tag":255,"props":365,"children":366},{"style":307},[367],{"type":56,"value":310},{"type":47,"tag":255,"props":369,"children":370},{"style":313},[371],{"type":56,"value":316},{"type":47,"tag":255,"props":373,"children":374},{"style":319},[375],{"type":56,"value":376},"hello\n",{"type":47,"tag":255,"props":378,"children":380},{"class":257,"line":379},5,[381,386,390],{"type":47,"tag":255,"props":382,"children":383},{"style":307},[384],{"type":56,"value":385},"    type",{"type":47,"tag":255,"props":387,"children":388},{"style":313},[389],{"type":56,"value":316},{"type":47,"tag":255,"props":391,"children":392},{"style":319},[393],{"type":56,"value":394},"io.kestra.plugin.core.log.Log\n",{"type":47,"tag":255,"props":396,"children":398},{"class":257,"line":397},6,[399,404,408],{"type":47,"tag":255,"props":400,"children":401},{"style":307},[402],{"type":56,"value":403},"    message",{"type":47,"tag":255,"props":405,"children":406},{"style":313},[407],{"type":56,"value":316},{"type":47,"tag":255,"props":409,"children":410},{"style":319},[411],{"type":56,"value":412},"\"Hello from the webhook\"\n",{"type":47,"tag":255,"props":414,"children":416},{"class":257,"line":415},7,[417,422],{"type":47,"tag":255,"props":418,"children":419},{"style":307},[420],{"type":56,"value":421},"triggers",{"type":47,"tag":255,"props":423,"children":424},{"style":313},[425],{"type":56,"value":354},{"type":47,"tag":255,"props":427,"children":429},{"class":257,"line":428},8,[430,434,438,442],{"type":47,"tag":255,"props":431,"children":432},{"style":313},[433],{"type":56,"value":363},{"type":47,"tag":255,"props":435,"children":436},{"style":307},[437],{"type":56,"value":310},{"type":47,"tag":255,"props":439,"children":440},{"style":313},[441],{"type":56,"value":316},{"type":47,"tag":255,"props":443,"children":444},{"style":319},[445],{"type":56,"value":446},"webhook\n",{"type":47,"tag":255,"props":448,"children":450},{"class":257,"line":449},9,[451,455,459],{"type":47,"tag":255,"props":452,"children":453},{"style":307},[454],{"type":56,"value":385},{"type":47,"tag":255,"props":456,"children":457},{"style":313},[458],{"type":56,"value":316},{"type":47,"tag":255,"props":460,"children":461},{"style":319},[462],{"type":56,"value":463},"io.kestra.plugin.core.trigger.Webhook\n",{"type":47,"tag":255,"props":465,"children":467},{"class":257,"line":466},10,[468,473,477],{"type":47,"tag":255,"props":469,"children":470},{"style":307},[471],{"type":56,"value":472},"    key",{"type":47,"tag":255,"props":474,"children":475},{"style":313},[476],{"type":56,"value":316},{"type":47,"tag":255,"props":478,"children":479},{"style":319},[480],{"type":56,"value":481},"\"{{ secret('WEBHOOK_KEY') }}\"\n",{"type":47,"tag":48,"props":483,"children":484},{},[485,487,493],{"type":56,"value":486},"The ",{"type":47,"tag":136,"props":488,"children":490},{"className":489},[],[491],{"type":56,"value":492},"main",{"type":56,"value":494}," segment is what the current documentation shows. Our Threat Watch record writes the same position as a tenant segment, so confirm the exact path for your version before you configure a forwarder.",{"type":47,"tag":78,"props":496,"children":497},{},[498,514],{"type":47,"tag":82,"props":499,"children":500},{},[501],{"type":47,"tag":86,"props":502,"children":503},{},[504,509],{"type":47,"tag":90,"props":505,"children":506},{},[507],{"type":56,"value":508},"Expose",{"type":47,"tag":90,"props":510,"children":511},{},[512],{"type":56,"value":513},"Do not expose",{"type":47,"tag":116,"props":515,"children":516},{},[517],{"type":47,"tag":86,"props":518,"children":519},{},[520,525],{"type":47,"tag":123,"props":521,"children":522},{},[523],{"type":56,"value":524},"The one webhook path per flow, reachable only through the relay",{"type":47,"tag":123,"props":526,"children":527},{},[528,530,536,538,543],{"type":56,"value":529},"The UI, ",{"type":47,"tag":136,"props":531,"children":533},{"className":532},[],[534],{"type":56,"value":535},"/api/v1/",{"type":56,"value":537}," flow and execution routes, ",{"type":47,"tag":136,"props":539,"children":541},{"className":540},[],[542],{"type":56,"value":141},{"type":56,"value":544},", plugin endpoints",{"type":47,"tag":48,"props":546,"children":547},{},[548],{"type":56,"value":549},"With a relay you do not publish the Kestra hostname at all. The provider talks to a Webhook Relay URL and only the relay output knows the Kestra address.",{"type":47,"tag":71,"props":551,"children":553},{"id":552},"step-by-step-webhook-relay-next-to-kestra",[554],{"type":56,"value":555},"Step by step: Webhook Relay next to Kestra",{"type":47,"tag":48,"props":557,"children":558},{},[559,561,567,569,575],{"type":56,"value":560},"This follows the ",{"type":47,"tag":127,"props":562,"children":564},{"href":563},"/docs/webhooks/internal/localhost/",[565],{"type":56,"value":566},"localhost forwarding guide",{"type":56,"value":568}," and the ",{"type":47,"tag":127,"props":570,"children":572},{"href":571},"/docs/installation/docker-compose/",[573],{"type":56,"value":574},"Docker Compose installation page",{"type":56,"value":576},".",{"type":47,"tag":48,"props":578,"children":579},{},[580,585,587,593],{"type":47,"tag":52,"props":581,"children":582},{},[583],{"type":56,"value":584},"1. Create a bucket and an internal output.",{"type":56,"value":586}," In the dashboard, create a bucket (for example ",{"type":47,"tag":136,"props":588,"children":590},{"className":589},[],[591],{"type":56,"value":592},"kestra-hooks",{"type":56,"value":594},"). Its public input URL is the address the provider will use. Add an output whose destination is the full Kestra webhook URL, key included, as seen from the agent. Or with the CLI, as in the guide:",{"type":47,"tag":246,"props":596,"children":600},{"className":597,"code":598,"language":599,"meta":7,"style":7},"language-bash shiki shiki-themes github-dark","relay bucket create kestra-hooks\nrelay input create --bucket kestra-hooks \"public endpoint\"\nrelay output create kestra-flow --bucket kestra-hooks \\\n  --destination http://kestra:8080/api/v1/main/executions/webhook/company.team/webhook_example/YOUR_KEY\n","bash",[601],{"type":47,"tag":136,"props":602,"children":603},{"__ignoreMap":7},[604,628,660,694],{"type":47,"tag":255,"props":605,"children":606},{"class":257,"line":258},[607,613,618,623],{"type":47,"tag":255,"props":608,"children":610},{"style":609},"--shiki-default:#B392F0",[611],{"type":56,"value":612},"relay",{"type":47,"tag":255,"props":614,"children":615},{"style":319},[616],{"type":56,"value":617}," bucket",{"type":47,"tag":255,"props":619,"children":620},{"style":319},[621],{"type":56,"value":622}," create",{"type":47,"tag":255,"props":624,"children":625},{"style":319},[626],{"type":56,"value":627}," kestra-hooks\n",{"type":47,"tag":255,"props":629,"children":630},{"class":257,"line":325},[631,635,640,644,650,655],{"type":47,"tag":255,"props":632,"children":633},{"style":609},[634],{"type":56,"value":612},{"type":47,"tag":255,"props":636,"children":637},{"style":319},[638],{"type":56,"value":639}," input",{"type":47,"tag":255,"props":641,"children":642},{"style":319},[643],{"type":56,"value":622},{"type":47,"tag":255,"props":645,"children":647},{"style":646},"--shiki-default:#79B8FF",[648],{"type":56,"value":649}," --bucket",{"type":47,"tag":255,"props":651,"children":652},{"style":319},[653],{"type":56,"value":654}," kestra-hooks",{"type":47,"tag":255,"props":656,"children":657},{"style":319},[658],{"type":56,"value":659}," \"public endpoint\"\n",{"type":47,"tag":255,"props":661,"children":662},{"class":257,"line":343},[663,667,672,676,681,685,689],{"type":47,"tag":255,"props":664,"children":665},{"style":609},[666],{"type":56,"value":612},{"type":47,"tag":255,"props":668,"children":669},{"style":319},[670],{"type":56,"value":671}," output",{"type":47,"tag":255,"props":673,"children":674},{"style":319},[675],{"type":56,"value":622},{"type":47,"tag":255,"props":677,"children":678},{"style":319},[679],{"type":56,"value":680}," kestra-flow",{"type":47,"tag":255,"props":682,"children":683},{"style":646},[684],{"type":56,"value":649},{"type":47,"tag":255,"props":686,"children":687},{"style":319},[688],{"type":56,"value":654},{"type":47,"tag":255,"props":690,"children":691},{"style":646},[692],{"type":56,"value":693}," \\\n",{"type":47,"tag":255,"props":695,"children":696},{"class":257,"line":357},[697,702],{"type":47,"tag":255,"props":698,"children":699},{"style":646},[700],{"type":56,"value":701},"  --destination",{"type":47,"tag":255,"props":703,"children":704},{"style":319},[705],{"type":56,"value":706}," http://kestra:8080/api/v1/main/executions/webhook/company.team/webhook_example/YOUR_KEY\n",{"type":47,"tag":48,"props":708,"children":709},{},[710],{"type":56,"value":711},"Use your own Kestra port and a long random key. Only buckets with an internal output are streamed to agents.",{"type":47,"tag":48,"props":713,"children":714},{},[715,720,722,728,730,736,738,743,745,751],{"type":47,"tag":52,"props":716,"children":717},{},[718],{"type":56,"value":719},"Lock the destination path.",{"type":56,"value":721}," By default an output appends any extra path the sender adds to the input URL (a POST to ",{"type":47,"tag":136,"props":723,"children":725},{"className":724},[],[726],{"type":56,"value":727},"\u003Cinput URL>/anything",{"type":56,"value":729}," goes to ",{"type":47,"tag":136,"props":731,"children":733},{"className":732},[],[734],{"type":56,"value":735},"\u003Cdestination>/anything",{"type":56,"value":737},"). Outputs created in the dashboard have ",{"type":47,"tag":52,"props":739,"children":740},{},[741],{"type":56,"value":742},"Destination URL path locked",{"type":56,"value":744}," switched on; outputs created with the CLI or API do not, so open the output and turn it on (API/MCP field ",{"type":47,"tag":136,"props":746,"children":748},{"className":747},[],[749],{"type":56,"value":750},"lock_path: true",{"type":56,"value":752},"). With the path locked, every delivery goes to exactly the destination URL and nothing else on Kestra is reachable through the relay. It matters here because CVE-2026-49869 came down to which path suffix the authentication filter trusted, so do not let the sender choose any part of the path Kestra receives.",{"type":47,"tag":48,"props":754,"children":755},{},[756,761,763,770,772,778],{"type":47,"tag":52,"props":757,"children":758},{},[759],{"type":56,"value":760},"2. Create an access token",{"type":56,"value":762}," on the ",{"type":47,"tag":127,"props":764,"children":767},{"href":765,"rel":766},"https://my.webhookrelay.com/tokens",[131],[768],{"type":56,"value":769},"tokens page",{"type":56,"value":771}," and put the key and secret in an ",{"type":47,"tag":136,"props":773,"children":775},{"className":774},[],[776],{"type":56,"value":777},".env",{"type":56,"value":779}," file next to your compose file:",{"type":47,"tag":246,"props":781,"children":783},{"className":597,"code":782,"language":599,"meta":7,"style":7},"RELAY_KEY=\"your-access-token-key\"\nRELAY_SECRET=\"your-access-token-secret\"\nBUCKETS=kestra-hooks\n",[784],{"type":47,"tag":136,"props":785,"children":786},{"__ignoreMap":7},[787,806,823],{"type":47,"tag":255,"props":788,"children":789},{"class":257,"line":258},[790,795,801],{"type":47,"tag":255,"props":791,"children":792},{"style":313},[793],{"type":56,"value":794},"RELAY_KEY",{"type":47,"tag":255,"props":796,"children":798},{"style":797},"--shiki-default:#F97583",[799],{"type":56,"value":800},"=",{"type":47,"tag":255,"props":802,"children":803},{"style":319},[804],{"type":56,"value":805},"\"your-access-token-key\"\n",{"type":47,"tag":255,"props":807,"children":808},{"class":257,"line":325},[809,814,818],{"type":47,"tag":255,"props":810,"children":811},{"style":313},[812],{"type":56,"value":813},"RELAY_SECRET",{"type":47,"tag":255,"props":815,"children":816},{"style":797},[817],{"type":56,"value":800},{"type":47,"tag":255,"props":819,"children":820},{"style":319},[821],{"type":56,"value":822},"\"your-access-token-secret\"\n",{"type":47,"tag":255,"props":824,"children":825},{"class":257,"line":343},[826,831,835],{"type":47,"tag":255,"props":827,"children":828},{"style":313},[829],{"type":56,"value":830},"BUCKETS",{"type":47,"tag":255,"props":832,"children":833},{"style":797},[834],{"type":56,"value":800},{"type":47,"tag":255,"props":836,"children":837},{"style":319},[838],{"type":56,"value":839},"kestra-hooks\n",{"type":47,"tag":48,"props":841,"children":842},{},[843,848,850,856,858,863,864,869,870,875],{"type":47,"tag":52,"props":844,"children":845},{},[846],{"type":56,"value":847},"3. Add the agent as a compose service.",{"type":56,"value":849}," The docs' compose example uses ",{"type":47,"tag":136,"props":851,"children":853},{"className":852},[],[854],{"type":56,"value":855},"webhookrelay/webhookrelayd:latest",{"type":56,"value":857}," with ",{"type":47,"tag":136,"props":859,"children":861},{"className":860},[],[862],{"type":56,"value":794},{"type":56,"value":276},{"type":47,"tag":136,"props":865,"children":867},{"className":866},[],[868],{"type":56,"value":813},{"type":56,"value":284},{"type":47,"tag":136,"props":871,"children":873},{"className":872},[],[874],{"type":56,"value":830},{"type":56,"value":876},". Add it to the same file as Kestra, and do not publish Kestra's port:",{"type":47,"tag":246,"props":878,"children":880},{"className":295,"code":879,"language":297,"meta":7,"style":7},"services:\n  kestra:\n    image: kestra/kestra:latest\n    # no \"ports:\" entry: Kestra is only reachable on the compose network\n    # ... your existing Kestra configuration ...\n\n  relay:\n    container_name: webhookrelay\n    image: webhookrelay/webhookrelayd:latest\n    restart: always\n    environment:\n      - RELAY_KEY=${RELAY_KEY}\n      - RELAY_SECRET=${RELAY_SECRET}\n      - BUCKETS=${BUCKETS}\n",[881],{"type":47,"tag":136,"props":882,"children":883},{"__ignoreMap":7},[884,896,908,925,934,942,951,963,980,996,1013,1026,1040,1053],{"type":47,"tag":255,"props":885,"children":886},{"class":257,"line":258},[887,892],{"type":47,"tag":255,"props":888,"children":889},{"style":307},[890],{"type":56,"value":891},"services",{"type":47,"tag":255,"props":893,"children":894},{"style":313},[895],{"type":56,"value":354},{"type":47,"tag":255,"props":897,"children":898},{"class":257,"line":325},[899,904],{"type":47,"tag":255,"props":900,"children":901},{"style":307},[902],{"type":56,"value":903},"  kestra",{"type":47,"tag":255,"props":905,"children":906},{"style":313},[907],{"type":56,"value":354},{"type":47,"tag":255,"props":909,"children":910},{"class":257,"line":343},[911,916,920],{"type":47,"tag":255,"props":912,"children":913},{"style":307},[914],{"type":56,"value":915},"    image",{"type":47,"tag":255,"props":917,"children":918},{"style":313},[919],{"type":56,"value":316},{"type":47,"tag":255,"props":921,"children":922},{"style":319},[923],{"type":56,"value":924},"kestra/kestra:latest\n",{"type":47,"tag":255,"props":926,"children":927},{"class":257,"line":357},[928],{"type":47,"tag":255,"props":929,"children":931},{"style":930},"--shiki-default:#6A737D",[932],{"type":56,"value":933},"    # no \"ports:\" entry: Kestra is only reachable on the compose network\n",{"type":47,"tag":255,"props":935,"children":936},{"class":257,"line":379},[937],{"type":47,"tag":255,"props":938,"children":939},{"style":930},[940],{"type":56,"value":941},"    # ... your existing Kestra configuration ...\n",{"type":47,"tag":255,"props":943,"children":944},{"class":257,"line":397},[945],{"type":47,"tag":255,"props":946,"children":948},{"emptyLinePlaceholder":947},true,[949],{"type":56,"value":950},"\n",{"type":47,"tag":255,"props":952,"children":953},{"class":257,"line":415},[954,959],{"type":47,"tag":255,"props":955,"children":956},{"style":307},[957],{"type":56,"value":958},"  relay",{"type":47,"tag":255,"props":960,"children":961},{"style":313},[962],{"type":56,"value":354},{"type":47,"tag":255,"props":964,"children":965},{"class":257,"line":428},[966,971,975],{"type":47,"tag":255,"props":967,"children":968},{"style":307},[969],{"type":56,"value":970},"    container_name",{"type":47,"tag":255,"props":972,"children":973},{"style":313},[974],{"type":56,"value":316},{"type":47,"tag":255,"props":976,"children":977},{"style":319},[978],{"type":56,"value":979},"webhookrelay\n",{"type":47,"tag":255,"props":981,"children":982},{"class":257,"line":449},[983,987,991],{"type":47,"tag":255,"props":984,"children":985},{"style":307},[986],{"type":56,"value":915},{"type":47,"tag":255,"props":988,"children":989},{"style":313},[990],{"type":56,"value":316},{"type":47,"tag":255,"props":992,"children":993},{"style":319},[994],{"type":56,"value":995},"webhookrelay/webhookrelayd:latest\n",{"type":47,"tag":255,"props":997,"children":998},{"class":257,"line":466},[999,1004,1008],{"type":47,"tag":255,"props":1000,"children":1001},{"style":307},[1002],{"type":56,"value":1003},"    restart",{"type":47,"tag":255,"props":1005,"children":1006},{"style":313},[1007],{"type":56,"value":316},{"type":47,"tag":255,"props":1009,"children":1010},{"style":319},[1011],{"type":56,"value":1012},"always\n",{"type":47,"tag":255,"props":1014,"children":1016},{"class":257,"line":1015},11,[1017,1022],{"type":47,"tag":255,"props":1018,"children":1019},{"style":307},[1020],{"type":56,"value":1021},"    environment",{"type":47,"tag":255,"props":1023,"children":1024},{"style":313},[1025],{"type":56,"value":354},{"type":47,"tag":255,"props":1027,"children":1029},{"class":257,"line":1028},12,[1030,1035],{"type":47,"tag":255,"props":1031,"children":1032},{"style":313},[1033],{"type":56,"value":1034},"      - ",{"type":47,"tag":255,"props":1036,"children":1037},{"style":319},[1038],{"type":56,"value":1039},"RELAY_KEY=${RELAY_KEY}\n",{"type":47,"tag":255,"props":1041,"children":1043},{"class":257,"line":1042},13,[1044,1048],{"type":47,"tag":255,"props":1045,"children":1046},{"style":313},[1047],{"type":56,"value":1034},{"type":47,"tag":255,"props":1049,"children":1050},{"style":319},[1051],{"type":56,"value":1052},"RELAY_SECRET=${RELAY_SECRET}\n",{"type":47,"tag":255,"props":1054,"children":1056},{"class":257,"line":1055},14,[1057,1061],{"type":47,"tag":255,"props":1058,"children":1059},{"style":313},[1060],{"type":56,"value":1034},{"type":47,"tag":255,"props":1062,"children":1063},{"style":319},[1064],{"type":56,"value":1065},"BUCKETS=${BUCKETS}\n",{"type":47,"tag":48,"props":1067,"children":1068},{},[1069,1071,1077],{"type":56,"value":1070},"The Docker Compose page's own example uses ",{"type":47,"tag":136,"props":1072,"children":1074},{"className":1073},[],[1075],{"type":56,"value":1076},"network_mode: host",{"type":56,"value":1078}," so the agent can reach services on the host's localhost. The agent can deliver to any destination it can reach, so keep it on the compose network when Kestra is a sibling service, and use host networking only if Kestra is bound to the host.",{"type":47,"tag":48,"props":1080,"children":1081},{},[1082,1087,1088,1094],{"type":47,"tag":52,"props":1083,"children":1084},{},[1085],{"type":56,"value":1086},"4. Start it",{"type":56,"value":857},{"type":47,"tag":136,"props":1089,"children":1091},{"className":1090},[],[1092],{"type":56,"value":1093},"docker compose up -d",{"type":56,"value":1095},", then put the bucket's public input URL into the provider's webhook settings.",{"type":47,"tag":48,"props":1097,"children":1098},{},[1099,1104,1105,1111],{"type":47,"tag":52,"props":1100,"children":1101},{},[1102],{"type":56,"value":1103},"5. Test",{"type":56,"value":857},{"type":47,"tag":136,"props":1106,"children":1108},{"className":1107},[],[1109],{"type":56,"value":1110},"curl -X POST \u003Cinput URL> -d '{\"hello\":\"world\"}'",{"type":56,"value":1112}," and check the execution in Kestra and the request in the bucket's logs.",{"type":47,"tag":48,"props":1114,"children":1115},{},[1116,1118,1123],{"type":56,"value":1117},"The firewall rule you need is the outbound HTTPS you already allow. The provider can no longer reach Kestra's UI, ",{"type":47,"tag":136,"props":1119,"children":1121},{"className":1120},[],[1122],{"type":56,"value":141},{"type":56,"value":1124}," or flow-creation routes, because it never learns where Kestra is.",{"type":47,"tag":71,"props":1126,"children":1128},{"id":1127},"hardening-that-still-matters",[1129],{"type":56,"value":1130},"Hardening that still matters",{"type":47,"tag":1132,"props":1133,"children":1134},"ul",{},[1135,1146,1156,1188,1221,1231],{"type":47,"tag":1136,"props":1137,"children":1138},"li",{},[1139,1144],{"type":47,"tag":52,"props":1140,"children":1141},{},[1142],{"type":56,"value":1143},"Patch.",{"type":56,"value":1145}," Upgrade to 1.3.21 (1.3.x) or 1.0.45 (1.0.x), or later. The relay does not fix the bug.",{"type":47,"tag":1136,"props":1147,"children":1148},{},[1149,1154],{"type":47,"tag":52,"props":1150,"children":1151},{},[1152],{"type":56,"value":1153},"A long random key.",{"type":56,"value":1155}," The trigger key is the whole authentication on the webhook route. Generate it, store it as a Kestra Secret, and rotate it if it appears in a log or a ticket. With the relay, the provider never sees it: only the output destination holds it.",{"type":47,"tag":1136,"props":1157,"children":1158},{},[1159,1164,1166,1172,1174,1180,1181,1187],{"type":47,"tag":52,"props":1160,"children":1161},{},[1162],{"type":56,"value":1163},"Verify signatures.",{"type":56,"value":1165}," GitHub, Stripe and others sign their deliveries. Check the HMAC at the relay with a ",{"type":47,"tag":127,"props":1167,"children":1169},{"href":1168},"/docs/webhooks/auth/hmac/",[1170],{"type":56,"value":1171},"function",{"type":56,"value":1173}," that stops forwarding on a mismatch, or in the flow. See ",{"type":47,"tag":127,"props":1175,"children":1177},{"href":1176},"/blog/verify-webhook-signature/",[1178],{"type":56,"value":1179},"how to verify a webhook signature",{"type":56,"value":284},{"type":47,"tag":127,"props":1182,"children":1184},{"href":1183},"/blog/webhook-security/",[1185],{"type":56,"value":1186},"webhook security best practices",{"type":56,"value":576},{"type":47,"tag":1136,"props":1189,"children":1190},{},[1191,1196,1198,1204,1206,1211,1213,1219],{"type":47,"tag":52,"props":1192,"children":1193},{},[1194],{"type":56,"value":1195},"Filter.",{"type":56,"value":1197}," ",{"type":47,"tag":127,"props":1199,"children":1201},{"href":1200},"/docs/webhooks/functions/filter-webhooks/",[1202],{"type":56,"value":1203},"Filter or drop webhooks before forwarding",{"type":56,"value":1205}," lets you forward only the events and methods a flow wants, for example only pushes to ",{"type":47,"tag":136,"props":1207,"children":1209},{"className":1208},[],[1210],{"type":56,"value":492},{"type":56,"value":1212},". ",{"type":47,"tag":127,"props":1214,"children":1216},{"href":1215},"/features/forwarding-rules/",[1217],{"type":56,"value":1218},"Forwarding rules",{"type":56,"value":1220}," can also match on URL path, JSON fields and IP address. Dropped requests stay in the logs. Filtering reduces what reaches the key-protected route; it is not a replacement for the key.",{"type":47,"tag":1136,"props":1222,"children":1223},{},[1224,1229],{"type":47,"tag":52,"props":1225,"children":1226},{},[1227],{"type":56,"value":1228},"Private network.",{"type":56,"value":1230}," Restrict the Kestra port to trusted hosts. Do not publish it in compose.",{"type":47,"tag":1136,"props":1232,"children":1233},{},[1234,1239,1241,1246],{"type":47,"tag":52,"props":1235,"children":1236},{},[1237],{"type":56,"value":1238},"Assume compromise if it was exposed unpatched.",{"type":56,"value":1240}," Look for flows or executions named ",{"type":47,"tag":136,"props":1242,"children":1244},{"className":1243},[],[1245],{"type":56,"value":174},{"type":56,"value":1247},", unexpected script-task executions and deleted logs, outbound calls to cloud metadata endpoints, and rotate secrets and cloud credentials the worker could reach.",{"type":47,"tag":48,"props":1249,"children":1250},{},[1251,1253,1259],{"type":56,"value":1252},"For the CI-server version of this pattern, see ",{"type":47,"tag":127,"props":1254,"children":1256},{"href":1255},"/blog/webhooks-in-ci-cd/",[1257],{"type":56,"value":1258},"receiving webhooks in CI/CD",{"type":56,"value":576},{"type":47,"tag":71,"props":1261,"children":1263},{"id":1262},"honest-limits",[1264],{"type":56,"value":1265},"Honest limits",{"type":47,"tag":48,"props":1267,"children":1268},{},[1269],{"type":56,"value":1270},"A relay is not a patch, and it does not fix everything.",{"type":47,"tag":1132,"props":1272,"children":1273},{},[1274,1291,1301,1311],{"type":47,"tag":1136,"props":1275,"children":1276},{},[1277,1282,1284,1289],{"type":47,"tag":52,"props":1278,"children":1279},{},[1280],{"type":56,"value":1281},"The webhook path itself.",{"type":56,"value":1283}," If a future bug sits in the webhook trigger, the relay still forwards the request to it. Verify the provider's signature at the relay with an ",{"type":47,"tag":127,"props":1285,"children":1286},{"href":1168},[1287],{"type":56,"value":1288},"HMAC function",{"type":56,"value":1290},": unsigned requests get a 401 there and never reach Kestra, so only genuine deliveries can touch the trigger. With the key and filtering, that is your defence there; a flaw triggered by content inside a genuine delivery still needs the patch.",{"type":47,"tag":1136,"props":1292,"children":1293},{},[1294,1299],{"type":47,"tag":52,"props":1295,"children":1296},{},[1297],{"type":56,"value":1298},"Humans who need the UI from outside.",{"type":56,"value":1300}," Use a VPN or an identity-aware proxy for them. A webhook relay carries webhooks, not a browser session.",{"type":47,"tag":1136,"props":1302,"children":1303},{},[1304,1309],{"type":47,"tag":52,"props":1305,"children":1306},{},[1307],{"type":56,"value":1308},"Workers or clients that connect from outside.",{"type":56,"value":1310}," If remote workers or API clients must reach Kestra, they need a path to it, and that path has to be authenticated and restricted on its own terms.",{"type":47,"tag":1136,"props":1312,"children":1313},{},[1314,1319],{"type":47,"tag":52,"props":1315,"children":1316},{},[1317],{"type":56,"value":1318},"Internal attackers.",{"type":56,"value":1320}," The advisory says network access is enough. The relay does nothing against an attacker already on your network.",{"type":47,"tag":71,"props":1322,"children":1324},{"id":1323},"related-reading",[1325],{"type":56,"value":1326},"Related reading",{"type":47,"tag":1132,"props":1328,"children":1329},{},[1330,1339,1348,1357,1366,1374,1382,1390,1398],{"type":47,"tag":1136,"props":1331,"children":1332},{},[1333],{"type":47,"tag":127,"props":1334,"children":1336},{"href":1335},"/blog/jenkins-vulnerabilities/",[1337],{"type":56,"value":1338},"Jenkins vulnerabilities exploited in the wild",{"type":47,"tag":1136,"props":1340,"children":1341},{},[1342],{"type":47,"tag":127,"props":1343,"children":1345},{"href":1344},"/blog/teamcity-vulnerabilities/",[1346],{"type":56,"value":1347},"TeamCity CVE-2023-42793 and CVE-2024-27198",{"type":47,"tag":1136,"props":1349,"children":1350},{},[1351],{"type":47,"tag":127,"props":1352,"children":1354},{"href":1353},"/blog/n8n-security/",[1355],{"type":56,"value":1356},"n8n security after CVE-2025-68613",{"type":47,"tag":1136,"props":1358,"children":1359},{},[1360],{"type":47,"tag":127,"props":1361,"children":1363},{"href":1362},"/blog/langflow-vulnerabilities/",[1364],{"type":56,"value":1365},"Langflow vulnerabilities, from CVE-2025-3248 on",{"type":47,"tag":1136,"props":1367,"children":1368},{},[1369],{"type":47,"tag":127,"props":1370,"children":1371},{"href":221},[1372],{"type":56,"value":1373},"Securing Jenkins: a hardening checklist",{"type":47,"tag":1136,"props":1375,"children":1376},{},[1377],{"type":47,"tag":127,"props":1378,"children":1379},{"href":1255},[1380],{"type":56,"value":1381},"Receiving webhooks in CI/CD",{"type":47,"tag":1136,"props":1383,"children":1384},{},[1385],{"type":47,"tag":127,"props":1386,"children":1387},{"href":1183},[1388],{"type":56,"value":1389},"Webhook security best practices",{"type":47,"tag":1136,"props":1391,"children":1392},{},[1393],{"type":47,"tag":127,"props":1394,"children":1395},{"href":1176},[1396],{"type":56,"value":1397},"How to verify a webhook signature",{"type":47,"tag":1136,"props":1399,"children":1400},{},[1401],{"type":47,"tag":127,"props":1402,"children":1403},{"href":563},[1404],{"type":56,"value":1405},"Receive webhooks on localhost with the relay CLI",{"type":47,"tag":48,"props":1407,"children":1408},{},[1409,1411,1418],{"type":56,"value":1410},"If your Kestra is public only so a provider can reach a flow, ",{"type":47,"tag":127,"props":1412,"children":1415},{"href":1413,"rel":1414},"https://my.webhookrelay.com/register",[131],[1416],{"type":56,"value":1417},"create a bucket",{"type":56,"value":1419}," and close the port.",{"type":47,"tag":1421,"props":1422,"children":1423},"style",{},[1424],{"type":56,"value":1425},"html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}",{"title":7,"searchDepth":343,"depth":343,"links":1427},[1428,1429,1430,1431,1432,1433,1434],{"id":73,"depth":325,"text":76},{"id":200,"depth":325,"text":203},{"id":229,"depth":325,"text":232},{"id":552,"depth":325,"text":555},{"id":1127,"depth":325,"text":1130},{"id":1262,"depth":325,"text":1265},{"id":1323,"depth":325,"text":1326},"markdown","content:blog:kestra-security.md","content","blog/kestra-security.md","blog/kestra-security","md",{"loc":4},1793261894983]