Managing Store Credentials in CI Secrets

Protect the credentials that can publish your extension — Chrome refresh tokens, AMO JWT keys, App Store Connect keys and signing keys — with scoped secrets, approval gates and rotation.

Published September 18, 2026 Updated September 18, 2026 7 min read
Table of Contents

The credentials a release pipeline holds can push code to every installed copy of your extension. That makes them among the most valuable secrets in the project — more valuable than most production database passwords, because a compromised publishing token reaches every user’s browser with the permissions they already granted. Extension supply-chain attacks have used exactly this route. The protections are ordinary secret-management practice applied with unusual strictness. This guide is part of CI and release automation.

What each credential can do

Publishing credentials and their blast radiusThe Chrome Web Store refresh token, AMO JWT issuer and secret, App Store Connect API key and a self-hosted CRX signing key compared on what an attacker could do with each and how it is revoked.CredentialAttacker canRevoke viaCWS refresh tokenUpload and submit a versionGoogle account securityAMO JWT issuer + secretSign and submit a versionAMO API keys pageApp Store Connect keySubmit an app buildApp Store ConnectSelf-hosted CRX keySign a trusted update, no reviewCannot — it is the id
Every row can ship code to users — the only differences are the review step in between and how fast you can revoke.

The last row deserves emphasis: a self-hosted extension’s private signing key cannot be revoked. It is the extension’s identity, and whoever holds it can sign updates your users’ browsers will install. Treat it like a root certificate.

Step-by-step

1. Keep each credential in the narrowest secret scope

1# GitHub Actions: secrets on an environment, not the repository
2jobs:
3  publish-chrome:
4    environment: chrome-web-store     # secrets defined here are only visible to this job
5    steps:
6      - run: ./scripts/cws-publish.sh
7        env:
8          CWS_REFRESH_TOKEN: ${{ secrets.CWS_REFRESH_TOKEN }}

Execution context: GitHub Actions. Environment-scoped secrets are unavailable to other jobs and to workflows triggered from forks. A repository-level secret, by contrast, is readable by any workflow any contributor can modify — including one that echos it into a log.

2. Require a human before any publishing job runs

Configure the environment with required reviewers and restrict it to the release branch or tags. The publishing job then pauses until a named person approves it.

1on:
2  push:
3    tags: ["v*"]
4jobs:
5  publish-chrome:
6    if: startsWith(github.ref, 'refs/tags/v')
7    environment: chrome-web-store

Execution context: GitHub Actions. The approval gate means a compromised developer account or a malicious pull request cannot publish on its own — it must also get past a second person. That is the property that makes the credential in step 1 safe to hold in CI at all.

3. Never let a secret reach a log or an artifact

1set -euo pipefail
2set +x                                   # never trace commands that contain secrets
3curl -s https://oauth2.googleapis.com/token \
4  --data-urlencode "refresh_token=${CWS_REFRESH_TOKEN}" \
5  -d client_id="$CWS_CLIENT_ID" -d client_secret="$CWS_CLIENT_SECRET" \
6  -d grant_type=refresh_token > /tmp/token.json
7ACCESS_TOKEN=$(jq -r .access_token /tmp/token.json); rm -f /tmp/token.json
8echo "::add-mask::$ACCESS_TOKEN"

Execution context: the publishing script in CI. CI masks registered secrets in logs, but derived values — the minted access token — are not registered unless you mask them. set +x prevents shell tracing from printing the command line with the secret embedded. Build artifacts must never contain credentials either; a leaked key in a shipped bundle is the failure covered in handling API keys without shipping them in the bundle.

4. Use a dedicated publishing identity

Publish from a Google account, AMO account and Apple team role that exist only for publishing, protected by hardware-key two-factor authentication, owned by the organisation rather than an individual, and with no other use.

1publishing@example.com
2  - 2-step verification: security keys only
3  - Owner of: Chrome Web Store item "Reader"
4  - Used by: CI refresh token only
5  - Recovery: two named admins, documented in the runbook

Execution context: account administration, not code. A personal account used for publishing means the developer’s personal phishing risk is the extension’s supply-chain risk, and their departure is an outage.

5. Rotate on a schedule and on any doubt

1# A weekly job that proves the credential still works, without publishing.
2on:
3  schedule: [{ cron: "0 6 * * 1" }]
4jobs:
5  check-cws-credential:
6    environment: chrome-web-store
7    steps:
8      - run: ./scripts/cws-token-check.sh   # mints a token, reads item status, publishes nothing

Execution context: GitHub Actions. A weekly check catches a revoked token before release day. Rotation itself — issuing a new refresh token, AMO key or Apple key, updating the secret, revoking the old one — should happen at least annually and immediately whenever someone with access leaves or a CI system is suspected of compromise.

Layers between a leaked credential and your usersFour layers of defence: environment-scoped secrets, a required human approval, a dedicated publishing identity with hardware 2FA, and store review before users receive an update.Environment-scoped secretinvisible to other jobs and forksstops casual exfiltrationRequired approvalnamed reviewer, tags onlystops a single compromised accountDedicated identity + hardware 2FAorganisation-ownedstops credential phishingStore reviewbefore users updateabsent for self-hosted builds
Store review is the last layer, not the first — and self-hosted distribution does not have it at all.

Responding to a suspected leak

If a publishing credential may have been exposed — a secret printed in a public log, a compromised CI runner, a departed contractor with access — act in this order.

  1. Revoke first. Delete the AMO API key, revoke the Google OAuth grant, revoke the App Store Connect key. Speed matters more than diagnosis.
  2. Check what was published. Look at each store’s version history for submissions you did not make. On the Chrome Web Store, a pending submission you do not recognise can be cancelled before review completes.
  3. Issue new credentials into the environment secrets and run the weekly check job to confirm they work.
  4. Audit the pipeline for how the secret was exposed and close that path.

For self-hosted distribution, a leaked signing key cannot be revoked; the only remedy is a new extension id, which means every user must reinstall. That asymmetry is the strongest argument for store distribution wherever it is possible — as discussed in publishing private and enterprise extensions.

Incident response for a leaked publishing tokenRevocation within minutes, a store history check within the hour, new credentials the same day, and a pipeline audit over the following days.leak noticed+3 daysRevokeminutesCheck store historywithin the hourNew credentialssame dayPipeline auditdaysattacker locked outreleases possible again
Revoke before investigating — a few minutes of exposure is the difference that matters.

Cross-browser variation

  • Chrome Web Store: an OAuth client id, client secret and refresh token for a Google account with access to the item. Revocation is through the account’s security settings or deleting the OAuth client.
  • Firefox (AMO): a JWT issuer and secret from the API keys page; regenerating the key revokes the old one immediately.
  • Safari / App Store: an App Store Connect API key (.p8) with a key id and issuer id; keys are revoked in App Store Connect under Users and Access.
  • All three: none of these credentials should ever be on a developer’s laptop after initial setup. CI holds them; people approve their use.

Verification

  1. Confirm no workflow outside the publishing environment can read the secrets — attempt to reference one from a branch workflow and confirm it resolves to empty.
  2. Search recent CI logs for anything token-shaped:
1gh run list --limit 20 --json databaseId -q '.[].databaseId' | \
2  xargs -I{} gh run view {} --log | grep -Ec 'ya29\.|1//0|eyJhbGci' 
3# 0

Execution context: your shell with the GitHub CLI. ya29. and 1//0 are Google access and refresh token prefixes, eyJhbGci a JWT header; any hit is a masking gap.

  1. Trigger a publish from a non-tag branch and confirm it does not run.
  2. Run the weekly credential check manually and confirm it reports success without publishing.

FAQ

Is it safe to store credentials in a password manager instead?

For the one-time setup, yes. For ongoing use, the credentials should live in CI, where they are used by an audited process rather than copied to laptops.

Should every developer be able to publish?

No. Everyone can prepare a release; a small named group approves publication. The approval gate is what enforces it.

What about OpenID Connect instead of long-lived secrets?

Where a store supports federated identity from your CI provider, prefer it — short-lived, workload-bound tokens remove the long-lived secret entirely. Support varies by store; check before assuming it is unavailable.

Other Testing, Debugging & Performance Optimization Resources