GitHub Actions OIDC already gets a job into AWS, Azure and Google Cloud without a stored key. The production Postgres password and the deploy host’s SSH key usually stay in GitHub Actions secrets anyway, because neither system can verify a token from GitHub. Formal can: the job presents its GitHub OIDC token to Formal, and the Connector logs in to the database or host on its behalf.

How GitHub Actions OIDC works with Formal

GitHub Actions OIDC (OpenID Connect) gives each job a JSON Web Token signed by GitHub’s OIDC provider, https://token.actions.githubusercontent.com, whose claims name the repository, its owner, the branch or tag, the workflow and the account that started the run. Formal accepts that token as the job’s identity: it checks it against an OIDC integration, maps the job to a machine user, and lets it mint a short-lived Connector login for Postgres, SSH and other resources.

The part that differs from a cloud setup is the audience. GitHub sets aud to the URL of the repository owner unless the job asks for something else, and the first cause of a 401 in Formal’s troubleshooting list is an aud that isn’t exactly oidc.formal.ai/<integration_id>. So the job requests a token for Formal by name. A job that also deploys to AWS asks a second time with sts.amazonaws.com, and each token then names the one service it was issued for.

Since GitHub lets you choose the audience, Formal’s guide recommends checking that exact value inside the claim condition as well.

The job never receives the database password. It sends its GitHub OIDC token to Formal’s API as a Bearer token and gets back a federation token: a Connector password signed by Formal that carries the GitHub claims. It logs in to the Connector with that. The Connector evaluates its policies and opens the upstream connection with a Native User, the credential you configured once on the Connector. From there a CI job gets the same per-protocol handling as an engineer, described on the Postgres and SSH integration pages.

Replace GitHub Actions secrets with an OIDC integration

The trust is two Terraform resources from the OIDC integration guide: a machine user, and the integration that maps GitHub tokens to it.

resource "formal_user" "ci" {
  type = "machine"
  name = "github-actions"
}

resource "formal_integration_oidc" "github_actions" {
  name            = "GitHub Actions"
  issuer          = "https://token.actions.githubusercontent.com"
  machine_user_id = formal_user.ci.id
  claim_condition = "claims.repository_owner_id == \"<OWNER_ID>\" && claims.repository_id == \"<REPOSITORY_ID>\""
  status          = "active"
}

output "oidc_audience" {
  value = formal_integration_oidc.github_actions.audience
}

terraform output oidc_audience prints oidc.formal.ai/integrationoidc_..., the audience the workflow will ask GitHub for. In the Control Plane, you create the same integration from the GitHub Actions preset. Setting the status to draft switches authentication off without deleting the trust.

We changed one line from the guide’s Terraform example: the claim condition. The example matches claims.repository.startsWith("formalco/"), a prefix on a name. That accepts every repository in the organization, including ones created next year, and names can change hands. The condition above comes from the Endpoint’s GitHub Actions setup and checks the numeric owner and repository IDs instead. GitHub moved its own default subject claim the same way: repositories created after July 15, 2026 get a sub that includes both IDs, because a recycled namespace could otherwise produce a subject that matches an old trust policy (GitHub’s OIDC reference). GitHub notes that the change doesn’t reach GitHub Enterprise Server.

Database access from a GitHub Actions job

The workflow side needs one permission and no secrets. This job puts GitHub’s documented token request (from its custom-action example) in front of the mint and connect steps from the Formal guide. It’s a sketch assembled from both docs, not a workflow copied from a run of ours:

permissions:
  id-token: write
  contents: read

jobs:
  migrate:
    runs-on: ubuntu-latest
    steps:
      - name: Migrate through the Formal Connector
        run: |
          OIDC_JWT=$(curl -s -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
            "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=oidc.formal.ai/<INTEGRATION_ID>" | jq -r '.value')
          RESP=$(curl -s -X POST "https://api.formal.ai/core.v1.ConnectorService/CreateFederationToken" \
            -H "Authorization: Bearer $OIDC_JWT" -H "Content-Type: application/json" -d '{}')
          FORMAL_USER=$(echo "$RESP" | jq -r .username)
          FORMAL_TOKEN=$(echo "$RESP" | jq -r .token)
          psql "host=<CONNECTOR_HOST> port=5432 dbname=<DB> user=$FORMAL_USER password=$FORMAL_TOKEN" -f migrate.sql

Timing matters at both ends of the token’s life. Postgres takes it as a Formal JWT, so the login works as soon as it’s minted, while some MySQL and S3 paths match stored credentials and need the token to sync to the Connector first; there, retry for a few seconds. At the other end, the federation token expires at min(upstream OIDC JWT exp, now + 1 hour), and GitHub doesn’t publish how long its OIDC tokens live. Don’t assume an hour. Read expires_at from the mint response, and mint again per connection in a job that runs long.

Plan the network path early. The runner has to reach the Connector’s listener, and a Connector on a private network is usually easiest to reach from a self-hosted runner on that network.

SSH from a GitHub Actions job

For GitHub Actions SSH access the job mints the same token, and two things change. The SSH username carries the resource after a +. And the password is the token’s SHA-256 hash behind a formalhash: prefix, since OpenSSH cuts off anything past 1,023 bytes and a token full of GitHub claims usually runs longer:

export SSHPASS="formalhash:$(printf '%s' "$FEDERATION_TOKEN" | sha256sum | cut -d' ' -f1)"
sshpass -e ssh -p <SSH_PORT> "<USERNAME>+<RESOURCE_NAME>@<CONNECTOR_HOST>"

The hash only matches once the token has synced to the Connector, so give the first attempt a short retry. SSH session policies read the same GitHub claims as the database policies do.

Long jobs: let the Endpoint renew the token

A deploy that runs for hours outlives a single federation token. The headless Formal Endpoint takes that over: with the GitHub Actions token source, it requests tokens with your integration’s audience and swaps in a fresh one ahead of each expiry until the job ends.

auth_mode = "oidc"

[oidc]
provider = "github"
integration_id = "integrationoidc_<id>"

Start it in the background inside the job that uses it, so it inherits the two ACTIONS_ID_TOKEN_REQUEST_* variables GitHub sets. Run unset FORMAL_API_KEY first, because an API key takes precedence over OIDC, then formal agent --headless; formal auth whoami should print the machine user. In a reusable workflow, every job that talks to Formal starts its own Endpoint. Our guide covers configuring and starting the Endpoint on GitHub-hosted and self-hosted runners. It doesn’t yet cover installing the Endpoint package on a runner image.

Policies on repository and branch

The claim condition decides which jobs authenticate at all. Connector policies decide what each of those jobs can open, resource by resource, and they can read any verified GitHub claim under input.user.oidc.claims. This session policy from the OIDC guide blocks by default and lets through one repository’s main branch:

package formal.v2

import future.keywords.if

default session := {
  "action": "block",
  "type": "block_with_formal_message"
}

session := {
  "action": "allow",
  "reason": "Trusted repository and branch"
} if {
  input.user.oidc.claims.repository == "formalco/monorepo"
  input.user.oidc.claims.ref == "refs/heads/main"
}

A job on a feature branch or a pull request of that repository passes the claim condition, mints a token, and is still refused at the Connector, because a missing or mismatched claim leaves the default block in place. Add a condition on the resource and staging can accept any branch while production takes main only. GitHub puts other useful claims in the token, such as environment and, for reusable workflows, job_workflow_ref (GitHub’s claim list), and a policy can match those the same way. Policy evaluation on source claims needs Connector 2.16.1 or later.

Query policies keep applying underneath. A reporting job that reads a customer table through the Connector gets the same masked columns an analyst would, and each connection lands in Sessions under the CI machine user with its query text, rows affected and policy actions. Control-plane audit events, the mint included, store a bounded copy of the token’s claims and never the raw JWT.

What’s supported today

CapabilityStatusSource
GitHub-hosted and self-hosted runnersDocumentedEndpoint OIDC login
GitHub Actions preset in the Control PlaneDocumentedEndpoint OIDC login
Claim condition on owner and repository IDs, sub or refDocumentedOIDC integrations, Endpoint OIDC login
Postgres through the Connector with a federation tokenDocumentedOIDC integrations
SSH through the Connector (formalhash: password)DocumentedOIDC integrations
MySQL and S3Documented, token sync needed on some pathsOIDC integrations
Session policies on repository and branchDocumented, Connector 2.16.1+OIDC integrations
Token renewal for long jobs (headless Endpoint)DocumentedEndpoint OIDC login
Reusable workflows (Endpoint started per job)DocumentedEndpoint OIDC login
Terraform resources for the integrationDocumentedOIDC integrations
Audit events without the raw JWTDocumentedOIDC integrations
Installing the Endpoint on a runner imageNot documented for GitHub runners
Kubernetes and HTTP API access from a jobNot documented for CI jobs
Per-person attribution of a GitHub runNot documented for GitHub tokens

Which GitHub Actions secrets can go, and which stay?

With a Formal OIDC integration in place, three kinds of GitHub Actions secrets can go: the Formal API key, the passwords for databases behind the Connector, and the SSH keys for hosts behind it. Cloud keys go too, but to your cloud provider’s own OIDC trust rather than to Formal. What stays for now is anything you don’t put behind the Connector, such as a code-signing key or a package registry token.

The ones that stay deserve the narrowest scope GitHub offers, because GitHub itself says log redaction “is not guaranteed” and that the runner “can only redact secrets used within the current job.” In March 2025, a compromised tj-actions/changed-files affected over 23,000 repositories by writing CI/CD secrets into their workflow logs (GHSA-mrrh-fwg8-r2c3). Our CI/CD secrets management guide covers that incident, the Trivy one after it, wider GitHub Actions security such as pinning actions to a commit SHA, and how to store what you can’t remove.

Do you still need cloud OIDC for AWS, Azure and GCP?

Yes. GitHub Actions OIDC with AWS, Azure, Google Cloud or HashiCorp Vault works through each provider’s own trust policy (Google Cloud calls it workload identity federation): the provider trades the GitHub token for its own short-lived access token, and Formal isn’t in that path. Keep aws-actions/configure-aws-credentials or your cloud’s login action for cloud APIs, and use Formal for what sits behind the Connector: databases and SSH hosts that have no way to trust a GitHub token themselves.

That’s how we run our own infrastructure. GitHub Actions and Terraform Cloud reach our AWS accounts with OIDC, as described in Security Debt We Never Created, and Terraform Cloud authenticates to our own Formal tenant with an OIDC integration rather than an API key (Giving Cloud Agents Production Access with OIDC). Cloud coding agents use the same integration and federation token: Devin adds a Slack approval before its access opens, which a CI job on main doesn’t need.