DEV Community

Cover image for Load Secrets from Proton Pass in GitHub Actions
Martin Patino
Martin Patino

Posted on • Originally published at martinpatino.com

Load Secrets from Proton Pass in GitHub Actions

Why I switched

A while back I started ungoogling my stack. Less surveillance, fewer trackers, fewer accounts living inside one ad company. Nothing dramatic. I'm not building a bunker. I just got tired of seeing the same checkout page I looked at six weeks ago follow me around the internet.

So the migration went one piece at a time. Email moved off Gmail (Proton Mail). Search moved off Google (Kagi for the daily, DuckDuckGo for the throwaway). Browser swapped to Brave. And the last meaningful piece, my password manager, moved off 1Password to Proton Pass.

That switch was almost completely painless. Vaults migrated. The browser extension works. 2FA codes live where they should. Family plan covers everyone. Done.

Except for one thing. Every GitHub Actions workflow I had used 1Password's load-secrets-action. It's one of those tiny tools you don't think about until it's gone. You drop an op://vault/item/field reference into your env block, the action hydrates real values at runtime, the secret never sits in plain text in your repo. Beautiful. I had so much muscle memory around it that the missing equivalent on Proton's side was the only thing stopping me from cutting the cord completely.

Then Proton released pass-cli, an official command-line tool that can authenticate against your Proton Pass vault and read items. The wrapper just didn't exist yet. So I built it: load-secrets-proton-pass.

What it actually does

You reference Proton Pass secrets in your workflow's env block using a pass://vault/item/field URI. The action installs the Proton Pass CLI on the runner, authenticates with a Personal Access Token, resolves each pass:// reference, masks the values in CI logs, and exports them to $GITHUB_ENV so downstream steps can read them as regular environment variables.

There's an optional .env template mode too. If you keep a .env.production.template with placeholders like DATABASE_URL=pass://prod/postgres/connection_string, the action renders a real .env.production for you. Useful when whatever you're deploying wants an actual file instead of env vars.

That's the whole product. No SaaS layer. No metrics phoning home. No "creator pro tier." It's a thin wrapper around CLI commands you could run yourself.

The flow

A workflow step references pass://vault/item/field -> the action hands the URI to pass-cli -> the CLI authenticates against your Proton Pass vault using the PAT -> resolved values get masked and pushed into $GITHUB_ENV -> any downstream step reads them like normal env vars.

The masking part matters. GitHub's ::add-mask:: directive tells the runner: "if you ever see this exact string in logs, redact it." So if your deploy script accidentally echo $DATABASE_URLs, you'll see *** in the build output instead of a leaked production connection string. It's not bulletproof (more on that below), but it's the same safety net 1Password's action gives you.

Quickstart

1. Mint a Proton Pass PAT

In the Proton Pass web app, head to your account settings and generate a Personal Access Token. The format is pst_xxxx::TOKENKEY. The chunk before the :: is the token ID, the chunk after is the actual secret. You need both halves stitched together with the double colon, in that exact format.

You also need to grant the token viewer access to each vault you want it to read from. This is scoped at vault granularity, not item granularity. Pick the vaults you actually need in CI (probably a dedicated ci or prod vault, not your personal one).

Copy the full token immediately. Proton only shows it once. There is no "view again." If you lose it, mint a new one.

2. Add it as a GitHub Actions secret

In your repo, go to Settings -> Secrets and variables -> Actions -> New repository secret. Name it PROTON_PASS_PAT and paste the token.

3. Reference your secrets with pass://

Inside any step's env block, point at a vault, an item, and a field:

env:
  DATABASE_URL: pass://prod/postgres/connection_string
  STRIPE_KEY: pass://prod/stripe/secret_key
Enter fullscreen mode Exit fullscreen mode

The syntax is pass://<vault-name>/<item-name>/<field-name>. Spaces in vault or item names work. URL-encode them if it feels safer.

4. Drop the action in

A minimal workflow:

name: deploy
on: push
jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: thisguymartin/load-secrets-proton-pass@v1
        env:
          DATABASE_URL: pass://prod/postgres/connection_string
          STRIPE_KEY: pass://prod/stripe/secret_key
        with:
          personal-access-token: ${{ secrets.PROTON_PASS_PAT }}
      - name: deploy
        run: ./scripts/deploy.sh
Enter fullscreen mode Exit fullscreen mode

5. Use the values downstream

After the load-secrets-proton-pass step runs, DATABASE_URL and STRIPE_KEY are real env vars in the runner. The deploy step reads them like anything else. No code change. No SDK import. No vault-specific syntax leaking into your application.

That's it. Mint, add, reference, use.

Stuff that tripped me up

A few things I hit while building and dogfooding this. Documenting them here so you don't have to step on the same rakes.

The PAT format is pst_xxxx::TOKENKEY, not just pst_xxxx. I'll admit I lost about an hour the first time. The double-colon section is the actual secret. If you only paste the first half, you get a vague auth failure that doesn't obviously point at the token format. The README spells it out now, but it's the kind of detail easy to skim past when you're moving fast.

PATs are scoped per vault. A token minted at the user level still needs explicit viewer role on each vault you want to read. Created a new vault and your CI suddenly can't find an item? That's why. Go grant the existing token access to the new vault.

GitHub runners don't have an OS keyring. By default, pass-cli tries to store its session key in the system keyring (macOS Keychain, GNOME Keyring, etc.). On a fresh Ubuntu runner there's nothing to store it in. The action sets PROTON_PASS_KEY_PROVIDER=fs so the CLI falls back to a filesystem-based key store, which works inside an ephemeral runner. You don't have to think about this. I just want you to know why it's there if you crack open the source.

Masking helps. It's not a force field. echo $DATABASE_URL will print ***. But base64-encoding the value, splitting it on a delimiter, or using it as part of a curl call against a misconfigured endpoint will leak it in shapes GitHub's masker can't predict. Treat the action as defense in depth. Don't log your secrets, mask or no mask.

Why I built it

The point isn't to reinvent 1Password's action. It's to give people who left 1Password (privacy, billing, vendor diversification, whatever your reason) the same ergonomic surface they already know. Same URI://vault/item/field mental model. Same shape of YAML. Same pass-cli muscle memory people building on Proton are already developing for local work.

The action is MIT licensed, community-maintained, and explicitly not affiliated with Proton AG. I asked nobody for permission. It's a thin wrapper around the public pass-cli commands, so if Proton breaks their CLI, this breaks too. If they ship something better, I'll happily deprecate this and use theirs.

I wrote it for myself first. I'm publishing it because if you're a developer who's been waiting on exactly this, no point making you build it again.

Try it


The flow diagram in this post was rendered with mer-inkdrop, another small tool I built. It takes Mermaid source and returns a hosted image URL for PR descriptions, or in this case a static SVG for a blog post. Same theme: small, single-purpose, MIT licensed.

Top comments (0)