9.1 Encrypted Secrets Architecture & Scopes
Key Takeaways
- GitHub Actions encrypted secrets use Libsodium box encryption (asymmetric public-key cryptography via Curve25519, Salsa20/ChaCha20, and Poly1305) where values are encrypted client-side in the browser or CLI using the repository/org public key and decrypted only within the runner execution memory.
- Secrets are organized across three hierarchical scopes: Organization secrets (shared across all repos, private repos, or selected repos), Repository secrets (isolated to one repository), and Environment secrets (bound to deployment environments and gated by protection rules).
- Precedence hierarchy resolves collisions strictly in the order: Environment Secrets > Repository Secrets > Organization Secrets, allowing stage-specific secrets to override global defaults seamlessly.
- GitHub enforces strict scaling limits: maximum 1,000 Organization secrets, 100 Repository secrets, and 100 Environment secrets per environment, with a maximum secret payload size of 48 KB (48,000 bytes).
- The runner automatically redacts secret values from execution logs, replacing matching substrings with `***`; however, masking does not protect transformed or encoded variants such as Base64 strings, URL-encoded tokens, or unquoted multi-line JSON payloads.
Encrypted Secrets Architecture & Scopes
Continuous Integration and Continuous Delivery (CI/CD) pipelines inherently require access to sensitive credentials—such as deployment keys, third-party API tokens, package registry credentials, and database connection strings. In GitHub Actions, sensitive data must never be hardcoded into workflow YAML files, committed to Git history, or stored in plaintext environment configuration variables. Instead, GitHub provides a dedicated, cryptographically secure secrets management subsystem.
Mastering encrypted secrets architecture, understanding how secrets are scoped and inherited across organization hierarchies, and navigating the operational nuances of runner log redaction are critical requirements for passing the GitHub Actions Certification (GH-200) examination.
1. Cryptographic Foundation: Libsodium Box Encryption
GitHub Actions encrypted secrets rely on asymmetric public-key cryptography powered by Libsodium (specifically, the crypto_box_seal / sealed boxes standard). This architecture ensures that secrets are encrypted client-side before they ever traverse the network to GitHub's infrastructure, and they remain encrypted at rest until temporarily decrypted inside the runner's execution memory.
+-----------------------------------------------------------------------------+
| LIBSODIUM ASYMMETRIC ENCRYPTION FLOW |
| |
| [CLIENT: Browser / gh CLI] [GITHUB REPOSITORY] |
| 1. Request Repo Public Key -------------------> GET /secrets/public-key |
| 2. Receive 256-bit Public Key <---------------- Return Curve25519 Key |
| 3. Encrypt payload with Libsodium |
| crypto_box_seal(secret, pubkey) |
| 4. Transmit Cyphertext -----------------------> Stored Encrypted at Rest |
| |
| [WORKFLOW EXECUTION TRIGGERED] |
| 5. GitHub Backend injects encrypted payload to Runner over TLS |
| 6. Runner decrypts secret into volatile process memory |
| 7. Secret injected into job step as environment variable / input |
+-----------------------------------------------------------------------------+
The Asymmetric Encryption Process
- Public Key Retrieval: When an administrator creates or updates a secret via the GitHub Web UI, REST API, or GitHub CLI (
gh secret set), the client requests the target scope's 256-bit public key (e.g.,GET /repos/{owner}/{repo}/actions/secrets/public-key). - Client-Side Encryption: The client uses Libsodium to generate an ephemeral key pair, computes a shared secret with the target's public key, and encrypts the secret plaintext using authenticated symmetric encryption (XSalsa20-Poly1305 or ChaCha20-Poly1305). The ciphertext and ephemeral public key are combined into a "sealed box."
- Storage at Rest: GitHub stores only the sealed ciphertext. Even GitHub platform engineers cannot decrypt the stored secret without the corresponding private key.
- Decryption at Runtime: When a workflow job referencing the secret starts, GitHub's secure orchestration service decrypts the secret using the target's private key and transmits the decrypted value directly to the runner agent over an encrypted TLS connection. Decrypted secrets exist solely within the runner's ephemeral process memory for the duration of the step.
[!IMPORTANT] Write-Only Visibility: Once a secret is created, its plaintext value can never be viewed, retrieved, or edited through the GitHub Web UI or API. Users can only overwrite the secret with a new value or delete it entirely. This write-only property prevents credential extraction by unauthorized administrators.
2. The Three Secret Scopes & Visibility Policies
GitHub Actions organizes encrypted secrets into three distinct architectural scopes: Organization Secrets, Repository Secrets, and Environment Secrets.
+-----------------------------------------------------------------------------+
| SECRET SCOPES HIERARCHY |
| |
| +---------------------------------------------------------------------+ |
| | 1. ENVIRONMENT SECRETS (Highest Precedence) | |
| | - Bound to specific environment (e.g., 'production', 'staging') | |
| | - Gated by protection rules (required reviewers, branch limits) | |
| +---------------------------------------------------------------------+ |
| | |
| v |
| +---------------------------------------------------------------------+ |
| | 2. REPOSITORY SECRETS (Medium Precedence) | |
| | - Scoped strictly to a single repository | |
| | - Accessible by all workflows in the repository | |
| +---------------------------------------------------------------------+ |
| | |
| v |
| +---------------------------------------------------------------------+ |
| | 3. ORGANIZATION SECRETS (Base Precedence) | |
| | - Centrally managed across the organization | |
| | - Governed by visibility policies (All, Private, Selected) | |
| +---------------------------------------------------------------------+ |
+-----------------------------------------------------------------------------+
1. Organization Secrets
Organization secrets allow enterprise and organization administrators to share credentials across multiple repositories without duplicate manual configuration.
- Visibility Policies:
- All repositories: Accessible by all public and private repositories in the organization (not recommended for sensitive production credentials).
- Private repositories: Accessible only by private and internal repositories within the organization.
- Selected repositories: Accessible only by an explicit, administrator-curated list of repository IDs. This is the enterprise best practice for least privilege.
2. Repository Secrets
Repository secrets are defined within an individual repository's settings (Settings > Secrets and variables > Actions). They are accessible only to workflows executing within that specific repository. Workflows executing on forks do not have access to the upstream repository's secrets.
3. Environment Secrets
Environment secrets are tied to specific deployment environments (e.g., production, staging, qa).
- Protection Rules Gating: Environment secrets are only populated and exposed to a runner after all configured environment protection rules (such as manual approval gates, wait timers, and branch/tag deployment restrictions) have been completely satisfied.
- Precedence Advantage: If a job declares
environment: production, any secret defined in theproductionenvironment overrides a secret of the same name defined at the repository or organization level.
3. Secret Precedence & Collision Resolution
When a workflow step references a secret expression such as ${{ secrets.DATABASE_URL }}, GitHub Actions evaluates available secrets in a strict hierarchical order. If secrets with identical names exist across multiple scopes, the most specific scope wins.
| Secret Name | Org Scope Value | Repo Scope Value | Env Scope (production) Value | Resolved Value for environment: production | Resolved Value (No Environment) |
|---|---|---|---|---|---|
DEPLOY_KEY | org-key-111 | repo-key-222 | prod-key-333 | prod-key-333 (Env overrides all) | repo-key-222 (Repo overrides Org) |
API_TOKEN | org-token-abc | repo-token-xyz | (Not Defined) | repo-token-xyz (Repo overrides Org) | repo-token-xyz (Repo overrides Org) |
CORP_PROXY | proxy.corp.internal | (Not Defined) | (Not Defined) | proxy.corp.internal (Org fallback) | proxy.corp.internal (Org fallback) |
name: Deploy Application
on:
push:
branches: [main]
jobs:
deploy-prod:
runs-on: ubuntu-latest
# Binding to environment unlocks Environment Secrets and enforces approval gates
environment:
name: production
url: https://api.production.example.com
steps:
- name: Checkout Code
uses: actions/checkout@v4
- name: Execute Deployment
env:
# Resolves via precedence: Environment > Repository > Organization
DATABASE_URL: ${{ secrets.DATABASE_URL }}
DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }}
run: |
./scripts/deploy.sh --db "$DATABASE_URL" --key "$DEPLOY_KEY"
4. Platform Limits & Capacity Constraints
GitHub Actions enforces hard capacity limits on encrypted secrets and configuration variables to prevent platform abuse and ensure reliable runner synchronization.
| Metric / Feature | Maximum Constraint | Architectural Details & Operational Impact |
|---|---|---|
| Organization Secrets | 1,000 secrets | Maximum number of secrets that can be stored per organization. |
| Repository Secrets | 100 secrets | Maximum number of secrets that can be stored per individual repository. |
| Environment Secrets | 100 secrets | Maximum number of secrets that can be stored per deployment environment. |
| Configuration Variables | 1,000 Org / 100 Repo / 100 Env | Plaintext variables accessed via ${{ vars.VAR_NAME }} share the same quantity caps. |
| Secret Payload Size | 48 KB (48,000 bytes) | Maximum size per individual secret value (sufficient for large RSA/SSH private keys). |
| Workflow Secrets Context | Limited to referenced secrets | Secrets are not passed to runners unless explicitly referenced in the workflow. |
| Fork Workflows | Zero secret access | Pull requests from forks cannot access upstream secrets on pull_request triggers. |
[!TIP] Managing Large Credentials: While 48 KB is ample for PEM-encoded X.509 certificates and SSH keys, it cannot accommodate large binary assets or complete truststores. For large credentials, store a Base64-encoded encrypted file in the repository and save only the symmetric decryption passphrase (or use an external enterprise vault) as a 48 KB secret.
5. Log Masking Mechanics & Limitations
To prevent accidental exposure of credentials in continuous integration output, GitHub Actions implements automated runner log masking (redaction).
+-----------------------------------------------------------------------------+
| RUNNER LOG MASKING ENGINE |
| |
| Workflow Step Execution: |
| run: echo "Connecting with token: ${{ secrets.API_TOKEN }}" |
| |
| Runner Stream Processing: |
| Raw Output: "Connecting with token: gh_secret_super_secret_99812" |
| Filter Action: Scans against registered secret values in memory |
| Masked Stream: "Connecting with token: ***" |
| |
| UI / Storage: Only the masked stream '***' is persisted to run logs. |
+-----------------------------------------------------------------------------+
How Automatic Masking Works
- When a job references
${{ secrets.SECRET_NAME }}, the runner agent registers the resolved plaintext string in an internal memory redaction table before executing any steps. - As stdout and stderr streams are emitted by processes running on the runner, the runner's log scrubber replaces every exact occurrence of the registered secret string with three asterisks (
***). - Masking applies across all step commands, action logs, and runner diagnostics.
Dynamic Secret Masking (add-mask)
When a workflow dynamically generates or fetches a secret during execution (for example, retrieving an ephemeral token via curl from HashiCorp Vault), it must register the secret with the masking engine using the add-mask workflow command:
# Register dynamic secret string with the runner masking engine
echo "::add-mask::$EPHEMERAL_TOKEN"
In JavaScript actions, the equivalent method is core.setSecret(ephemeralToken).
Critical Log Masking Limitations
The GH-200 exam frequently tests scenarios where log masking fails to protect secrets:
- Transformed / Encoded Secrets: Masking operates strictly via literal string matching. If a secret is transformed—such as Base64 encoded (
echo "$SECRET" | base64), hex encoded, or URL encoded—the transformed string does not match the registered raw secret and will be printed to logs in plain text! - Short Substrings (< 3 Characters): Values shorter than 3 characters are not masked to prevent entire log outputs from turning into asterisks.
- Structured JSON / YAML Objects: If an entire JSON object containing multiple key-value pairs is stored as a secret and subsequently parsed or reformatted with different whitespace or indentation, individual values within the JSON will not match the registered blob and will appear unmasked.
- Command-Line Process Arguments (argv): While stdout/stderr is masked, arguments passed to commands (e.g.,
curl -u user:$SECRET) may be visible to other local processes on self-hosted runners inspecting/procorps -ef.
An organization has defined an organization-level secret named DEPLOY_TOKEN visible to all repositories with value org-val. In a repository named payment-gateway, a repository-level secret named DEPLOY_TOKEN is defined with value repo-val. The repository also has a deployment environment named production containing an environment-level secret named DEPLOY_TOKEN with value prod-val. If a workflow job specifies environment: production and executes echo "${{ secrets.DEPLOY_TOKEN }}", which value is injected into the runner process memory?
A DevOps architect is designing an automated deployment pipeline and needs to understand the technical constraints of GitHub Actions encrypted secrets. Which statement accurately describes the storage capacity, encryption mechanism, and viewing permissions for secrets?
A continuous integration step executes a shell script that encodes an API token secret before sending it to a legacy remote endpoint: ENCODED_AUTH=$(echo -n "$API_SECRET" | base64) followed by echo "Header: Basic $ENCODED_AUTH". What happens to the encoded token value in the workflow execution logs?