Insight Tech APAC Blog Logo

Passing a Secret Between GitHub Jobs

trentsteenholdt
September 10, 2025

8 minutes to read

When building automation with GitHub Actions, there are times when you must generate a secret in one job and consume it in another. That might seem easy enough to do, but passing of created secrets at run time is actually not a thing and some thought is needed on how to do this.

For a recent customer requirement, I was tasked with automating end-to-end the provision of a PKI solution. In this situation, it was provisioning an Entra ID App Registration with a client secret. Job 1 creates the App Registration and generates a client secret and Job 2 deploys infrastructure and the software that needs this secret.

This post walks through the challenge, the solution, and why layering encryption, Azure Key Vault, and job outputs together gives you a secure, idempotent pattern.

Additional Note: Per GitHub’s documentation, the recommended approach for passing secrets between jobs is to store them in a vault such as Azure Key Vault. In this scenario, however, that method was not used because the second job ran on a hosted runner with an OIDC credential that, for specific reasons, could not be granted access to Azure resources.

The Problem

GitHub Actions makes passing values between jobs straightforward with outputs. But secrets are different:

  • Secrets should never appear in logs. GitHub masks secrets.*, but values written to outputs can be accidentally exposed if GitHub doesn’t detect it as a potential secret (don’t trust its detection).
  • Secrets may expire or rotate. Relying on a pipeline to take an output from something needs to be checked if it’s still a valid secret or not.
  • Idempotency matters. If every run creates a new secret, you end up with a mess of expired secrets in Entra ID and mismatched application credentials.

Given these constraints, our workflow needs to:

  1. Generate the secret in Job 1 if it hasn’t done so already.
  2. Store it securely in Azure Key Vault (ensuring reruns are safe). We’ll also implement logic to handle secret expiration.
  3. Encrypt the secret before passing it through GitHub outputs.
  4. Decrypt it securely in Job 2, and mask it as a secret.

Job 1

Job 1 Screenshot

Generate and Encrypt the Secret

In my Job 1 pipeline, I use PowerShell to automate the creation of the Entra App Registration and all its components like APIs, Roles and Access, and of course the secret. The PowerShell snippet below handles the app registration secret creation and encryption:

if ($GitHubActions) {
  Write-Output "client_id=$($app.AppId)" >> "$env:GITHUB_OUTPUT"

  if ($secret) {
    if ($EncryptionPassword) {
      # Flatten secure string to plain text for OpenSSL
      $encryptionPasswordPlainText = $EncryptionPassword | ConvertFrom-SecureString -AsPlainText
      
      # Build OpenSSL args (aes-256-cbc with PBKDF2 and base64)
      $opensslArgs = @(
        'enc','-aes-256-cbc','-pbkdf2','-salt','-a','-k',$encryptionPasswordPlainText
      )
      
      $encryptedData = $secret | & openssl @opensslArgs
      $encryptionPasswordPlainText = $null

      Write-Output "client_secret=$encryptedData" >> "$env:GITHUB_OUTPUT"
    }
  }
}

Key points about this implementation:

  • The EncryptionPassword is not the generated secret itself from the app registration (that’s $secret in the snippet above). EncryptionPassword is the encryption key for securing GitHub Actions outputs. To enable secure transfer between jobs, we use an intermediary secret (stored as a GitHub Environment Secret). We reference it as secrets.GITHUB_PASSWORD and pass it to the PowerShell script as the -EncryptionPassword parameter.
  • AES-256-CBC with PBKDF2 provides strong encryption. This transforms the plaintext secret (which is never logged) into encrypted data.
  • Base64 encoding ensures the output is safe for GitHub logs and YAML parsing. Since this appears as an output in GitHub logs, base64 encoding the salted hash prevents issues with special characters. That’s the string thats outputted, but again remember it’s not just a base64 encoding of the secrets. It’s layered with AES-256-CBC with PBKDF2

This approach ensures the secret is never exposed in plain text between jobs.

Cache in Azure Key Vault

You might wonder how Azure Key Vault fits into this workflow when we’re encrypting the client secret for GitHub outputs. The integration happens earlier in the script through logic that checks the secret’s lifecycle in Entra (since you can’t retrieve existing secrets from it once set). The workflow determines whether to renew an expiring secret and save it to Azure Key Vault, or retrieve an existing valid secret from the vault.


$secrets = Get-AzADAppCredential -ObjectId $app.Id

$now = Get-Date
$matchingSecret = $secrets | Where-Object { $_.EndDateTime -gt $now } | Sort-Object -Property EndDateTime -Descending | Select-Object -First 1

$threshold = $now.AddDays($RenewThresholdDays)

if ($AzureKeyVaultName -and $AzureKeyVaultResourceGroup) {
  $secretSecure = Get-AzKeyVaultSecret -VaultName $AzureKeyVaultName -Name $AzureKeyVaultSecretName -ErrorAction SilentlyContinue
  $secret = if ($secretSecure) { $secretSecure.SecretValue | ConvertFrom-SecureString -AsPlainText } else { $null }

  if ($secret) {
    Write-Host "✅ Found existing secret in Key Vault"
    if ($matchingSecret.EndDateTime -lt $threshold) {
      Write-Host "⚠️ Secret expiring soon. Rotating..."
      $newSecret = New-AzADAppCredential -ObjectId $app.Id -EndDate (Get-Date).AddYears(2)
      Set-AzKeyVaultSecret -VaultName $AzureKeyVaultName -Name $AzureKeyVaultSecretName -SecretValue (ConvertTo-SecureString -String $newSecret.SecretText -AsPlainText -Force)
      $secret = $newSecret.SecretText
    }
    else {
      Write-Host "✅ Secret is valid until $($matchingSecret.EndDateTime)"
    }
  }
  else {
    Write-Host "❌ No existing secret in Key Vault. Creating..."
    $newSecret = New-AzADAppCredential -ObjectId $app.Id -EndDate (Get-Date).AddYears(2)
    Set-AzKeyVaultSecret -VaultName $AzureKeyVaultName -Name $AzureKeyVaultSecretName -SecretValue (ConvertTo-SecureString -String $newSecret.SecretText -AsPlainText -Force)
    $secret = $newSecret.SecretText
  }
}

This logic ensures:

  • When a valid secret exists, the workflow reuses it.
  • When approaching expiration, the workflow rotates the secret and updates Azure Key Vault.
  • When no secret exists, the workflow creates one and stores it.

This design makes your GitHub pipeline and in effect, your application/solution idempotent.

Expose Outputs at the Job Level

After setting the outputs in Job 1’s step, you need to expose them at the job level so they’re accessible to downstream jobs. Add an outputs: section at the beginning of your job definition, before any steps:

jobs:
  deploy:
    outputs:
      client_id: $\{\{ steps.set_ejbca_entra_config.outputs.client_id \}\}
      client_secret: $\{\{ steps.set_ejbca_entra_config.outputs.client_secret \}\}

In this case, set_ejbca_entra_config was the id of the step run in this job.

This configuration makes the encrypted values available to dependent jobs via needs.deploy.outputs.client_secret.

Job 2

Decrypt in the Second Job

Job 2 Screenshot

With Job 1 complete, Job 2 needs to retrieve the encrypted output and decrypt it using the same OpenSSL before injecting it into the job environment:

  deploy-step2:
    needs: deploy
    steps:
      - name: Decrypt EJBCA Entra Client Secret
        run: |
          echo "➡️ Decrypting EJBCA Client Secret"
          cipher_clean="$(echo "$\{\{ needs.deploy.outputs.client_secret \}\}" | tr -d ' \n\r')"
          SECRET=$(echo "$cipher_clean" | base64 -d | openssl enc -aes-256-cbc -pbkdf2 -d -salt -k "$\{\{ secrets.GITHUB_PASSWORD \}\}")
          echo "::add-mask::$SECRET"
          echo "client_secret=$SECRET" >> $GITHUB_ENV

Key aspects of this decryption step:

  • tr -d ' \n\r' strips any YAML formatting artifacts (such as unexpected line breaks) that could corrupt the encrypted data.
  • ::add-mask:: ensures the decrypted secret never appears in GitHub logs. Note that this is still an output/environment variable, not a GitHub Secret.
  • Writing to $GITHUB_ENV makes the decrypted value available to all following steps in the job.

The decrypted secret can then be used in subsequent steps:

my_secret=$\{\{ env.client_secret \}\}

For the purposes of my situation, I pass the value into a Ansible job as idp_client_secret.

Ansible Job Screenshot

Summary

Hopefully this pattern can be of use to you! Just make sure that when applying this pattern, that you prioritise these security principles:

  • Both jobs must reference the same GitHub Actions environment secret (secrets.GITHUB_PASSWORD) as the encryption key,
  • These values should be immediately masked with ::add-mask:: to prevent disclosure in logs, and
  • Use vault like Azure Key Vault as the single source of truth with encrypted values only transported between jobs.