Guides

How to share API keys and .env files securely

For developers: scoped, short-lived keys, secret managers (Vault, AWS Secrets Manager, 1Password CLI, Doppler), when a one-time link fits, and rotating safely.

On this page
  1. Give each person and service its own key
  2. Keep shared values in a secret manager
  3. Share a .env file without sharing the values
  4. When a one-time link is the right tool
  5. Never in git, tickets or build output
  6. Rotate without breaking production

The best way to share an API key securely is not to share it. Give each person and each service its own key, scoped to what it needs and set to expire. When a value really has to be shared, such as a database password or a third-party key with one per account, keep it in a secret manager and grant people access to the manager, not to the value.

A one-time link is the right tool for the gaps: bootstrapping the first credential that opens the secret manager, or a one-off handover to someone outside your systems. Git, tickets and chat are never the right tool, and “I’ll just send you my .env” is how keys end up in all three.

Give each person and service its own key

A key that belongs to one person or one service can be revoked without breaking anyone else, and every call it makes is attributable. A shared key is a password with no audit trail.

  • Scope it. Stripe’s restricted keys limit a key to the resources it needs. GitHub’s fine-grained personal access tokens are limited to chosen repositories and permissions, and can be set to expire. AWS IAM policies should name actions and resources rather than *. ShareShield’s own API keys work the same way: scoped (a CI job that only sends secrets needs only secrets:create), bound to one organisation, and able to expire after 1 to 365 days.

  • Make it short-lived. The safest credential is one that’s useless by tomorrow. For AWS, use IAM Identity Center instead of long-lived access keys:

    aws sso login --profile staging
    aws sts get-caller-identity --profile staging
  • Let CI fetch its own credentials. GitHub Actions can authenticate to AWS, Azure and Google Cloud with OpenID Connect, so there’s no cloud key stored in the repository’s secrets at all:

    permissions:
      id-token: write
      contents: read
    steps:
      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123456789012:role/deploy-staging
          aws-region: eu-west-2
  • Use dynamic secrets where you can. Vault’s database secrets engine creates a database user per request with its own time to live, so nobody shares the database password because nobody has it.

Keep shared values in a secret manager

For the values that do have to be shared, “sharing” should mean adding someone to a policy, a vault or a project. Removing them later is then one change, and the value never travels.

ToolGood fitHow you give someone access
HashiCorp VaultSelf-hosted, many teams, dynamic secretsA policy on the secret’s path, attached to their identity
AWS Secrets ManagerWorkloads already on AWS, managed rotationAn IAM policy allowing secretsmanager:GetSecretValue on the secret
1Password (with the CLI)Small teams who already use 1Password for everything elseAdd them to the vault
DopplerPer-environment config for apps, synced to hosting platformsAdd them to the project, with access to the environments they need

Writing a value in and reading it back, without it ending up in your shell history:

# Vault (KV v2). "api_key=-" reads the value from stdin.
vault kv put secret/payments/stripe api_key=-
vault kv get -field=api_key secret/payments/stripe

# AWS Secrets Manager. Write the JSON to a file, upload it, delete the file.
aws secretsmanager create-secret --name payments/stripe \
  --secret-string file://stripe.json
aws secretsmanager get-secret-value --secret-id payments/stripe \
  --query SecretString --output text

# 1Password CLI
op read "op://Payments/stripe/credential"

# Doppler
doppler secrets get STRIPE_API_KEY --plain

Share a .env file without sharing the values

The .env file itself is the problem: a plain-text file of every secret an app uses, which people copy between laptops, paste into chat and commit by accident. Replace it with something that’s safe to share.

  1. Commit a .env.example with every variable name and no values, and keep .env in .gitignore.

  2. With 1Password, put references in the .env instead of values. The file then contains nothing secret and can be shared or even committed. op run resolves the references when the app starts, for whoever is signed in and has access to the vault:

    # .env
    STRIPE_API_KEY=op://Payments/stripe/credential
    DATABASE_URL=op://Payments/staging-db/url
    
    op run --env-file=.env -- npm run dev
  3. With Doppler, drop the file. doppler setup links the directory to a project and environment, and doppler run -- npm run dev injects the variables at start-up.

  4. With Vault, Vault Agent can render a .env or config file from a template on the machine that needs it, so the file is produced in place rather than sent.

Every secret manager has a first credential: the token, key or password that opens it. And sometimes the recipient simply isn’t in your systems: a vendor’s support engineer who needs a key for a day, or a freelancer’s laptop that needs a working .env this afternoon. For those, a link that opens once and expires is the honest answer.

  • Vault has its own one-time mechanism. Response wrapping returns a single-use token instead of the secret. You send the token; they unwrap it. If their unwrap fails because the token has already been used, someone else got there first, and you know.

    vault kv get -wrap-ttl=30m secret/payments/stripe   # prints a wrapping_token
    vault unwrap <wrapping_token>                       # run by the recipient
  • Vault unseal keys can be encrypted to each holder’s PGP key at vault operator init with -pgp-keys, which is better than sending them at all.

  • For everything else, use a one-time link. Set one view, an expiry of hours rather than days, and if you can, lock it to the recipient’s email address. From a script, ShareShield’s API does it in one call:

    jq -n --rawfile content .env.staging '{content: $content, expiryHours: 4, maxViews: 1}' \
    | curl -sS --fail-with-body -X POST "https://app.shareshield.net/api/v2/secrets" \
        -H "x-api-key: $SHARESHIELD_API_KEY" \
        -H "content-type: application/json" \
        --data @- \
    | jq -r .url

    The share link is as sensitive as the secret until it’s opened, so keep it out of CI logs. For a certificate or key file rather than text, file secrets send the file itself. How to send a password securely covers the rest of the routine: passcodes, confirming receipt, and what to do if the link has already been opened.

If you hand a key to someone outside your systems, create a new one for them and revoke it when they’re done. Don’t send them yours.

Never in git, tickets or build output

  • Git. Once a key has been pushed, assume it’s compromised: rewriting history with git filter-repo doesn’t reach existing clones, forks, CI caches or the provider’s own scanning. Rotate the key first, then clean up. To stop tracking a committed .env:

    echo ".env" >> .gitignore
    git rm --cached .env
    git commit -m "Stop tracking .env"

    Catch it before it happens with a pre-commit scan (gitleaks git -v scans the repository’s history) and GitHub’s push protection, which is free on public repositories and part of GitHub Secret Protection for private ones.

  • Tickets and chat. Jira and Linear comments are emailed to watchers and indexed for everyone on the project. A key pasted “just for a minute” stays for years.

  • CI logs. GitHub Actions masks registered secrets, but not values derived from them. Mask anything you compute with echo "::add-mask::$TOKEN", and never set -x in a step that handles secrets.

  • Container images. A secret passed as a build ARG or ENV is visible in the image’s layers and docker history. Use BuildKit secret mounts instead:

    RUN --mount=type=secret,id=npmrc,target=/root/.npmrc npm ci

    and build with docker build --secret id=npmrc,src=$HOME/.npmrc .

Rotate without breaking production

Rotate when something happens: a key appears anywhere it shouldn’t, someone with access leaves, or a vendor reports a breach. A rotation schedule nobody keeps is worse than none, because it gives false comfort.

  1. Create the new key alongside the old one. Most providers allow two at once: AWS IAM users can hold two access keys, and Stripe’s “roll key” lets the old key keep working for a period you choose.
  2. Deploy the new key through your secret manager, so every consumer picks it up.
  3. Watch for the old key still being used. AWS shows each access key’s last use; most providers have something similar.
  4. Revoke the old key once nothing uses it.

Where the provider supports it, automate the whole loop: AWS Secrets Manager can rotate RDS credentials on a schedule, and aws secretsmanager rotate-secret --secret-id payments/db runs one on demand. When someone leaves, offboarding shared credentials has the order to work through.

Send the next one as a link that expires.

ShareShield turns a password, key or file into a link that opens once and is then deleted. You can send a text secret without an account.

All guides