Back to resources
HIL
CI
Testing

No Secrets: How a Pod Behind NAT Runs in CI

The BenchPod lives in your lab, behind NAT; your CI runs on GitHub's cloud. They meet in the middle with a GitHub OIDC token and a device key — and nothing long-lived is ever stored.

Edward Viaene · June 20, 2026 · 3 min read

There's an awkward gap between where hardware lives and where CI runs. The BenchPod is plugged in somewhere on your bench, behind NAT, with no public address. Your GitHub Actions job runs on a throwaway cloud runner. For the job to flash and test real hardware, those two have to find each other — and the obvious way to do that, stashing an API key in repo secrets, is exactly what you don't want sprayed across every workflow.

The BenchPod model avoids the long-lived secret entirely. Here's how the pieces fit.

The pod dials out

The pod never waits for an inbound connection. It opens an outbound, authenticated WebSocket to embeddedci.com and keeps it open, identifying itself with a device key it holds locally. Because the connection is outbound, the pod works behind NAT or a firewall with no port-forwarding — the same way your laptop reaches a website. The server now has a live, authenticated channel to a named device without anything being exposed to the public internet.

The job proves which repo it is

When a GitHub Actions job wants to drive a pod, it doesn't present a stored password. It mints a short-lived GitHub OIDC token — a signed assertion that says "I am a job running in owner/repo." That requires one line in the workflow:

permissions:
  id-token: write   # lets the job mint a GitHub OIDC token
  contents: read

The server verifies that token against GitHub, reads which repository it came from, and exchanges it for a short-lived session scoped to just the devices that repo is allowed to drive. This is the same mechanism PyPI uses for Trusted Publishing — no password ever changes hands.

The allow-list decides what it can touch

Identity isn't authorization. In the EmbeddedCI web app, under BenchPod → GitHub Actions, you list which repositories are trusted and which devices each may drive — Any device, or a specific set. A repo that isn't on the list gets nothing; a repo that is can only reach the pods you named. So a shared fleet can serve many teams without any one repo being able to grab a board it shouldn't.

The tunnel

With both sides authenticated, the server bridges a raw byte tunnel between the job and the pod. The job's test talks to the device as if it were local — the full API, including SWD flashing and UART/scope capture — so the same pytest you run on your desk runs unchanged in CI, just pointed at a name:

pytest --benchpod-connection=embeddedci:benchpod-v1.0.0

What's not stored

Add it up and the long-lived secret is gone: the pod authenticates with a device key that never leaves it, the job authenticates with a token that's minted per-run and expires in minutes, and authorization lives in a server-side allow-list you control. There's no EMBEDDEDCI_API_KEY in your repo secrets to leak, rotate, or accidentally print to a log.

If a token can't be minted, the error says exactly why — not running inside a GitHub Action, a missing id-token: write permission, or a failed token request. The step-by-step setup is in Run HIL in GitHub Actions.