CI/CD with OIDC
Let a build job reach deployed machines with no secret in the repository, the CI store or the runner. It proves itself with a token its own platform mints.
Summary
Needs a fleet, which is on the Pro and Enterprise plans. See Fleets to create one.
Why a pipeline needs no key#
A long-lived credential in a CI secret store is readable by every workflow in the project, outlives the people who created it, and is rarely rotated on a schedule.
CI platforms already sign a short-lived token naming the repository, branch and workflow that is running. A binding tells Localport to accept that token for one identity, so the job authenticates as itself with no secret stored.
What you create here is a binding, not a key. It produces an audience, a string like lpa_... that names the binding. The audience authorises nothing on its own, so it stays readable in the dashboard and is safe to commit to a workflow file.
Create the binding#
Open Identities in the sidebar, then the Setup keys tab, then New setup key. Choose CI/CD pipeline.
- Name describes the binding in the list and in the audit log.
- Identity is the name the pipeline's certificates will carry, such as
firmware-deploy. Grants are written against it. - Token issuer is the OIDC provider whose signature is checked. It is prefilled with GitHub Actions,
https://token.actions.githubusercontent.com, and any conforming provider works. - Subject claim is the
suba token must carry, matched exactly. - Workflow file pins the
job_workflow_refclaim as well, so only that workflow file can obtain the identity. - Access ends and Allowed from work as they do on a setup key.
Wildcards are refused in both fields, so a subject claim like repo:acme/* cannot be saved. Create one binding per repository or per workflow.
Select Create binding. The audience is shown, and unlike a setup key it stays readable in the list afterwards.
A binding is not spent the way a setup key is. It authenticates every run of that workflow, and each run presents a fresh single-use token.
Use it in GitHub Actions#
Grant the job permission to request a token, then pass the audience:
jobs:
deploy:
runs-on: ubuntu-latest
permissions:
id-token: write
steps:
- name: Reach the gateway
run: |
localport access tcp://device1-example.eu.localport.dev:22 \
--audience lpa_YOUR_AUDIENCE -p 2222 &
sleep 2
ssh -p 2222 deploy@127.0.0.1 /opt/deploy.sh
permissions: { id-token: write } is the part people miss. Without it the runner refuses to mint a token and the agent has nothing to present.
The subject claim to pin for that job looks like this, and the exact string is printed in the run's own OIDC token:
repo:acme/firmware:ref:refs/heads/main
Use it on another platform#
Any provider that signs a standard OIDC token works. Put the token in LOCALPORT_OIDC_TOKEN and the agent uses it directly:
LOCALPORT_OIDC_TOKEN="$CI_JOB_JWT" \
LOCALPORT_OIDC_AUDIENCE=lpa_YOUR_AUDIENCE \
localport access tcp://device1-example.eu.localport.dev:22 -p 2222
That environment variable is checked before anything else, so it covers GitLab id_tokens:, Buildkite, and a projected Kubernetes service account token. Set the issuer and subject on the binding to match whatever that platform puts in the token.
--audience cannot be combined with --pem or --p12. Each names a credential, and the agent refuses the combination instead of choosing one.
What gets checked#
Every request is verified against the binding before a certificate is issued.
- The signature, against the issuer's published keys. Only asymmetric algorithms are accepted, so a token naming
HS256ornoneis refused. - The issuer, matching the binding exactly.
- The audience, which has to name this binding.
- The subject, matching exactly. No wildcards, ever.
- Any pinned claim, such as the workflow file.
- The expiry, which must be present. A token claiming more than an hour of life is refused.
- The token id, claimed once. A replayed token is refused even inside its own validity window.
A refusal does not say which check failed, so an unknown caller cannot map which bindings exist.
What the pipeline gets#
A certificate that lasts 15 minutes, held in memory for the life of the process and never written to disk.
There is no renewal loop and nothing to clean up afterwards. A job that runs longer than 15 minutes requests another certificate.
The identity reaches nothing until a grant names it. Write one on each fleet the pipeline needs, as you would for any other machine. See Grants and Access.
Revoke a binding#
Revoke setup key on the row stops the binding issuing anything. The next run fails, with no change to the workflow and nothing to rotate.
Certificates already issued keep working until they expire, which for a pipeline is at most fifteen minutes. The record is kept so what the binding issued stays attributable.
Frequently asked questions#
How do I connect to a private service from GitHub Actions without a secret?
Create a CI/CD binding in Localport, add permissions: { id-token: write } to the job, and pass the binding's audience to localport access --audience. The runner mints a token proving which repository and workflow is running, Localport verifies it against the binding, and the job gets a 15-minute certificate held in memory. Nothing is stored in the repository or the CI secret store.
What is the audience, and is it secret?
The audience is a string like lpa_... that names one binding. It is not a secret and authorises nothing on its own, so it stays readable in the dashboard and can be committed to a workflow file. What authorises the request is the signed token the CI platform mints, which only that repository and workflow can obtain.
Can I allow every repository in my organisation?
No. The subject claim is matched exactly and wildcards are refused, so repo:acme/* cannot be saved. Create one binding per repository, or per workflow, which also gives each of them its own identity in the access log and its own grants.
Does this work outside GitHub Actions?
Yes, with any provider that signs a standard OIDC token. Put the token in LOCALPORT_OIDC_TOKEN and the audience in LOCALPORT_OIDC_AUDIENCE, which covers GitLab id_tokens:, Buildkite and a projected Kubernetes service account token. Set the binding's issuer and subject to match what that platform puts in the token.
How long does the pipeline's certificate last?
15 minutes, in memory, with no renewal. A job that needs longer requests another, and one that outruns its certificate fails instead of silently holding a credential past the run. Nothing is written to the runner's disk at any point.
My job connects and is refused. What is wrong?
Check three things in order. The job needs permissions: { id-token: write }, or no token is minted at all. The binding's subject claim has to match the run's sub exactly, including the branch. And the identity needs a grant on the fleet you are reaching, since a valid certificate with no grant reaches nothing. The access log shows refusals with their reason.
Can a pull request from a fork use my binding?
Not unless its subject claim matches. A fork's token carries a different sub, so pinning the branch as repo:acme/firmware:ref:refs/heads/main excludes pull request runs entirely. Pinning the workflow file as well means only that file can obtain the identity, even from inside the same repository.
Is a binding single-use like a setup key?
No. A setup key is a secret, so the first machine that redeems it spends it. A binding holds no secret and authenticates every run of the workflow it names, because the platform signs a fresh token each time and Localport accepts each token id only once.
Can I use this to run database migrations against a private database?
Yes, and it is a common shape. Point the fleet device at your database, write a grant for the pipeline's identity naming just that device, and have the job forward a local port with localport access tcp://.... The migration connects to 127.0.0.1 and the credential disappears when the job ends.
How do I stop a pipeline's access?
Revoke the binding to stop it obtaining new certificates, or remove its grant to stop it reaching a particular fleet while it keeps working elsewhere. Removing a grant closes any connection the job holds at that moment. Revoking the binding takes effect on the next run.
What to read next#
- Grants and Access - what the pipeline's identity reaches
- Setup Keys - the same sheet, for a long-lived machine
- Certificates - every credential in one list
- Access Log - what each run actually reached
- Fleets - the machines on the other side
- CI/CD Previews - preview environments per branch