A deployment action typically looks like this, with a stored GitHub secret:
jobs:
deploy:
steps:
- run: |
curl -XPOST "https://yourserver/deploy" \
-H "Authorization: Bearer ${{ secrets.DEPLOY_API_TOKEN }}" \
--data-urlencode service=abcd \
--data-urlencode commit=${{ github.sha }}
A better way is to use OIDC, which is supported by GitHub Actions and is surprisingly easy to use.
It works in 4 steps:
-
Add the
id-token: writepermission to the workflow. GitHub Actions will provide a token and a URL, which can be used to retrieve an OIDC ID Token. -
Call the GitHub OIDC API to obtain the ID Token
-
Call your deploy API with the ID token.
-
On your server, verify the token and perform the deployment.
Putting it together
On the GitHub action side:
# This sets the ACTIONS_ID_TOKEN_* variables so we can get an ID Token
permissions:
id-token: write
jobs:
deploy:
steps:
- id: idtoken
# Get the ID Token and set it to the IDTOKEN variable
run: |
curl -s -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
"$ACTIONS_ID_TOKEN_REQUEST_URL" | \
jq -r '"IDTOKEN=" + (.value|tostring)' >> $GITHUB_OUTPUT
# Call our server with the ID Token
- run: |
curl -XPOST "https://yourserver/deploy" \
-H "Authorization: Bearer ${{ steps.idtoken.outputs.IDTOKEN }}"
Now let’s handle the ID Token on the server side.
The ID Token is a JSON Web Token that is signed by GitHub and contains many claims, such as the repository and the commit that triggered the workflow. On our server we have to:
-
Verify that the token was signed by GitHub, to ensure that our
/deployendpoint was called through GitHub Actions and not by an attacker sending a similar-looking token. -
Verify the token’s claims, to ensure that our endpoint was called from the repository we expect and not, for example, from another GitHub user’s repository.
The token’s claims also include useful metadata that we can use in our endpoint. In the workflow example above, the service and commit parameters were removed because similar data is included in the token.
Here’s how it may go:
import { createRemoteJWKSet, jwtVerify } from "jose";
router.post("/deploy", async ({ headers }) => {
const keys = await createRemoteJWKSet(
new URL("https://token.actions.githubusercontent.com/.well-known/jwks"),
);
const jwt = await jwtVerify(headers.authorization.split(" ").at(1), keys);
if (jwt.payload.iss !== "https://token.actions.githubusercontent.com")
throw new Error("Unauthorized");
if (!ALLOWED_REPOS.includes(jwt.payload.repository))
throw new Error("Unauthorized");
await deploy({
repository: jwt.payload.repository,
ref: jwt.payload.ref,
sha: jwt.payload.sha,
});
});
And to finish off, a full ID Token looks like this:
{
"actor": "n-e",
"actor_id": "1234",
"aud": "https://github.com/n-e",
"base_ref": "",
"check_run_id": "123456789",
"event_name": "workflow_dispatch",
"exp": 1772542150,
"head_ref": "",
"iat": 1772541850,
"iss": "https://token.actions.githubusercontent.com",
"job_workflow_ref": "n-e/github_playground/.github/workflows/oidc.yaml@refs/heads/main",
"job_workflow_sha": "shashashashashashashashashashashasha",
"jti": "52086814-ca58-4d68-b5e5-0eb21f1eb0df",
"nbf": 1772541550,
"ref": "refs/heads/main",
"ref_protected": "false",
"ref_type": "branch",
"repository": "n-e/github_playground",
"repository_id": "123456",
"repository_owner": "n-e",
"repository_owner_id": "1234",
"repository_visibility": "private",
"run_attempt": "1",
"run_id": "12345678910",
"run_number": "6",
"runner_environment": "github-hosted",
"sha": "shashashashashashashashashashashasha",
"sub": "repo:n-e/github_playground:ref:refs/heads/main",
"workflow": ".github/workflows/oidc.yaml",
"workflow_ref": "n-e/github_playground/.github/workflows/oidc.yaml@refs/heads/main",
"workflow_sha": "shashashashashashashashashashashasha"
}
What about cloud providers?
OIDC is supported by many cloud providers. However, what I found interesting is that the protocol is simple enough to implement directly — perhaps even simpler than managing long‑lived secrets and copying them around.