AI Code Tutorial · No. 05
Your .env is the easiest secret on your laptop to steal.
Every API key, database password and signing secret your project uses sits in one plaintext file, readable by anything that runs as you. This tutorial explains how to replace it with an age-encrypted file whose key lives in a 1Password vault and unlocks with Touch ID. It includes a brief that has a coding agent build it, prove it with dummy data, and leave the real secrets alone until you say go.
Why this matters.
The .env file was a good idea: keep secrets out of the code, out of git, and load them at start-up. But it left them in plaintext on disk, in a folder full of software you did not write. Any program running as your user can read it, and for that program the file has a convenient name in a predictable place.
Attackers know this. On 26 August 2025, malicious versions of the Nx build tool were published to npm with an install script that searched developer machines for secrets, even asking the AI command-line tools it found to help, and uploaded the results to GitHub. A month later the Shai-Hulud worm spread through more than 500 npm packages, harvesting tokens and cloud keys from developer environments. Neither needed an exploit. They ran with the rights of the developer who installed them.
The fix is to stop keeping the secrets in plaintext. Encrypt the file, keep the only key in your password manager, and decrypt in memory at the moment you start the app, after you approve with Touch ID. The project folder then holds ciphertext, which is useless to whatever reads it.
The exposure
Who can read your secrets.
The same laptop, two set-ups. The middle column is a plaintext .env. The right column is an encrypted file with its key in 1Password. Both columns are honest: some rows do not change.
| Who | How | Plaintext .env | Encrypted + vault |
|---|---|---|---|
| An install script in a package you add | Reads files in your project or home folder | Reads them | Ciphertext only |
| An AI tool or editor that indexes the folder | File contents end up in context, caches or transcripts | Reads them | Ciphertext only |
| Cloud sync and backups | iCloud Desktop & Documents, Dropbox, Time Machine, a zip you send | Reads them | Ciphertext only |
| A commit or a paste by mistake | git add of the wrong file, a screen share, a support ticket | Reads them | Ciphertext only |
| A copy of the disk | A backup drive, a lost laptop without disk encryption | Reads them | Ciphertext only |
| A script in a terminal you approved minutes ago | It can ask 1Password the way your start command does | Reads them | Short window |
| A program running as you while the app runs | Reads the running app’s environment or memory | Reads them | Reads them |
Plaintext waits. Ciphertext does not care.
A plaintext file is exposed every minute of every day, including the months when you do not touch the project. The encrypted set-up shrinks the exposure to the times you actually run the app, and to the terminal you approved.
Your coding agent is a reader too.
Agents read files to understand a project, and what they read can end up in a model’s context, a log or a transcript. With an encrypted file, the agent can build and test the whole workflow without ever seeing a real value.
The idea
One key in the vault, nothing in plaintext.
age encrypts to a short public key, so the project can hold the public half and encrypt at any time. The private half exists only in a 1Password item. Starting the app becomes one command that fetches the key, decrypts in memory, and hands the variables to your app.
age does the cryptography.
A small, audited tool with no settings to get wrong. Nobody writes their own encryption here. The file is authenticated, so a single changed byte makes decryption fail instead of loading altered values.
1Password holds the key.
One item per project in a vault set aside for development. The desktop app approves each terminal with Touch ID or your device password. No long-lived token sits in a file.
Your stack does the rest.
The wrapper is written in your project’s language and parses the file with the same dotenv library your app already uses, so quoting, multi-line values and precedence behave exactly as they did before.
Before you start
Create the Development vault.
Keep development keys out of your personal vault. A separate vault lets you see at a glance which items unlock project files, share them with a teammate later without sharing anything else, and never confuse a key with a login. Do this once, then reuse the vault for every project.
In the desktop app
Three clicks.
- 1Open and unlock the 1Password app. In the sidebar, select the + button next to your vaults. With several accounts, use the + beside the account that should own the vault.
- 2Name it Development. Add a description such as “Keys for local project secrets” and pick an icon if you like.
- 3Click Create.
Or from a terminal
# macOS with Homebrew; see the links below for Linux and Windows brew install age 1password-cli # any op command now asks for Touch ID op vault list # create the vault (once) op vault create Development \ --description "Keys for local project secrets" \ --icon gears # check: name and id, no secrets op vault get Development --format json
The workflow
Six phases, each with a gate.
The agent builds and proves everything with dummy keys and fake values. Your real file and your real vault are touched only in phases 3 to 5, by commands you run yourself.
Fig. 3 — From plaintext to encrypted, phase by phase
-
PHASE 0
Inspect
How the app loads .env, what the framework loads on its own, dev commands. Without opening any secret file.
Gate: You approve a plan
-
PHASE 1
Build
Setup, migrate, run, update and remove commands in your project’s language, plus a short guide.
Gate: Commands documented
-
PHASE 2
Test on dummies
Throwaway age keys, a mock op, fake values. Tamper, deny, break and interrupt everything.
Gate: All tests pass
-
PHASE 3
Setup
You run it: a new key pair, the private half stored straight into a new vault item.
Gate: Key only in the vault
-
PHASE 4
Migrate
You run it: encrypt .env, decrypt in memory with the vault key, compare byte for byte.
Gate: Identical bytes
-
PHASE 5
Remove plaintext
Start the app from the encrypted file, then delete .env after one more check.
Gate: No plaintext left
The agent stops before your secrets.
The brief tells the agent to finish after phase 2 and hand you the commands for phases 3 to 5. It must not run them, even later. Once you approve 1Password in a terminal, any command in that terminal can decrypt for the next few minutes without asking again, and that includes a command the agent runs.
Phase by phase
What the agent actually builds.
You do not need to type any of this. It is here so you can review what the agent writes. The examples call the wrapper secrets; yours will be a script in your project’s language, with whatever name fits.
Phase 0 · no secret files opened
Inspect how config arrives.
- Every place that loads a .env. Calls to a dotenv library in your code, and the framework’s own loader. Each one could quietly read a leftover plaintext file and has to be switched off when the app runs through the wrapper.
- Precedence today. Most loaders let a variable already set in your shell win over the file. The wrapper must keep whatever your app does now.
- Variable names from the code, not the file. The agent builds .env.example from the names your code reads, with placeholders, so it never opens the real file.
- The dev commands. How you start the app, the worker and the tests, so each gets a documented command through the wrapper.
- Anything that reaches the browser. Build steps that inline variables into client bundles, and templates that print config.
Gate: the agent reports every loader, the current precedence and the dev commands, using official documentation for your tool versions, and waits.
Phase 1
Build the commands.
- setup. Generates the key pair with its output captured, pipes a JSON item template with the private key into op item create - on standard input, reads it back to confirm, and keeps only the public key and the item’s IDs locally. It refuses if the item or the local config already exists.
- migrate. Encrypts .env to the public key, fetches the private key, decrypts in memory and compares byte for byte. Only then does it write .env.enc, atomically, never over an existing file without an explicit flag and a backup.
- run. Fetches, decrypts, parses, then replaces itself with your app, so the app keeps the same process and receives your arguments, signals and Ctrl-C directly, and its exit status is yours.
- set, unset, keys, remove-plaintext, status. Change one value without a plaintext file, list names without values, delete .env only after proving the encrypted copy matches, and check for leftovers.
The key goes in on standard input
# the wrapper does this with output captured; # the private key is never an argument age-keygen # → stdout, captured age-keygen -y # key on stdin → public key <item JSON with a CONCEALED field> | op item create --vault <vault-id> --format json -
Decrypt without a key file
# the key is written into an anonymous pipe and # age reads it from /dev/fd/N (macOS, Linux) op read --no-newline op://<vault-id>/<item-id>/age_identity age --decrypt -i /dev/fd/3 < .env.enc # → memory
Gate: every command exists, fails closed, and is described in a short guide with the exact commands for your project.
Phase 2 · dummy data only
Try to break it before it holds anything real.
- Real age, fake vault. Each test makes a throwaway key pair and puts a small mock op first on the PATH. The mock serves the dummy key, records its arguments, and can pretend Touch ID was denied.
- A leak check on every test. The wrapper’s output is searched for each dummy value and for the private-key prefix. The mock’s argument log is searched for the key.
- Break the protections on purpose. Remove one safeguard at a time in a scratch copy and check that a test fails. A test that still passes was not testing anything.
A dummy file that exercises the parser
# fake values only
PLAIN=dummy-plain-value
export EXPORTED=dummy-exported
SINGLE='dummy $NOT_EXPANDED # not a comment'
DOUBLE="dummy \"double\" tab\tend"
MULTI="line-one
line-two"
INTERP="${PLAIN}-suffix"
EMPTY=
SPACED = dummy-spaced # trailing comment
Gate: every test in the checklist below passes, no dummy secret appears in any output, and no temporary plaintext file is left anywhere.
Phases 3 and 4 · you run these
Setup, then migrate.
Approve Touch ID when asked. Setup prints the public key and the new item’s ID, never the private key. Migrate reports how many bytes it verified. Plaintext .env is still there afterwards, on purpose.
secrets setup # new item in the Development vault secrets migrate # .env → .env.enc, verified secrets keys # names only, from the encrypted file
Phase 5 · you run these
Run, then remove plaintext.
Start the app through the wrapper and use it for a while. When you are happy, remove the plaintext. The command decrypts once more and deletes .env only if both match exactly.
secrets run # default dev command secrets run -- <any command> # worker, tests, shell secrets remove-plaintext # type "delete" to confirm secrets status # leftovers, git-ignore check
The quiet fallback
Your framework loads .env too.
Injecting variables is half the job. If the app, or the framework under it, also reads .env at start-up, a forgotten plaintext file can fill in whatever the encrypted one lacks, and you would never notice. The wrapper has to turn those loaders off, and you have to know which variables end up in the browser.
| Stack | What it does | What the wrapper needs |
|---|---|---|
| Python, python-dotenv | load_dotenv() does not override variables that are already set. Recent versions honour PYTHON_DOTENV_DISABLED=1, which turns every load_dotenv() call into a no-op. | Set PYTHON_DOTENV_DISABLED=1 for the app |
| Flask CLI | flask run loads .env and .flaskenv itself when python-dotenv is installed. | Set FLASK_SKIP_DOTENV=1 |
| Node.js, --env-file | Values already in the environment win over the file. The file is only read if the flag is on the command line. | Drop --env-file from the wrapped command |
| Node.js, dotenv package | Does not change variables that are already set unless you pass override. | Guard or remove the config() call |
| Next.js | Loads .env* itself; process.env wins over every file. NEXT_PUBLIC_ variables are inlined into browser JavaScript at build time. | No .env* files left; no secret behind NEXT_PUBLIC_ |
| Vite | Existing variables win over .env files. Variables prefixed VITE_ are bundled into client code. | No .env* files left; no secret behind VITE_ |
Never source the file.
source .env or eval runs the file as shell code. A value with backticks or $(…) would execute. The wrapper parses it with a real dotenv library, the same one your app uses.
Public prefixes are public.
Encryption protects the file, not what your build does with it. Any variable with a browser prefix ends up in JavaScript anyone can download. The agent checks that no secret has one.
Day two
Changing a secret later.
Keys get rotated and new services get added. The everyday path never writes plaintext. The full-file editor does, so it is opt-in, with its limits spelled out.
Preferred
One value, no file.
secrets set OPENAI_API_KEY # hidden prompt, twice pbpaste | secrets set STRIPE_KEY # or from a pipe secrets unset OLD_TOKEN # then restart the app
The value is never an argument, so it stays out of shell history and the process list. Each change re-encrypts, checks the new file decrypts to what was intended, and keeps the previous ciphertext as a timestamped backup.
Opt-in
The whole file, in your editor.
secrets edit --plaintext-tempfile
- The file is created with owner-only permissions (600) inside a fresh owner-only directory (700) in your per-user temp folder, and the whole directory is deleted afterwards, on success, on error, on Ctrl-C and on SIGTERM.
- A crash, SIGKILL or power cut skips cleanup. The status command reports stale temp directories.
- Editors make copies of their own: vim swap, backup and undo files, Emacs auto-saves, VS Code hot-exit caches. For vim, start it with -n -i NONE and backups and undo files switched off.
- On SSDs and APFS, overwriting a file does not guarantee the old blocks are gone, and a snapshot or backup taken meanwhile keeps a copy.
Know the window
How the 1Password session works.
Touch ID does not approve one command. It approves a terminal for a while. Knowing exactly how long, and what locking does and does not do, is part of using this safely.
Per terminal
On macOS and Linux, approval covers that terminal session and the sub-shells inside it. Another terminal window asks again. On Windows, sub-shells need their own approval.
10 minutes idle, 12 hours max
The approval expires after 10 minutes without CLI use in that terminal, and after 12 hours in any case.
Locking revokes approval
Locking the 1Password app revokes every prior approval. The next CLI call asks again.
Locking does not stop the app
A process already running keeps the secrets it was started with, in memory and in its environment, until it exits. Lock the vault and restart the app to drop them.
Same user, same reach
Other programs running as you can read a running process’s environment, and its child processes inherit it. That part of the risk does not change.
Nothing cached
The wrapper keeps no copy of the key or the values between starts. Every start asks 1Password again; inside the approval window, that is silent.
The checklist
Every test the agent runs.
Eighteen checks, all on dummy data. Every failure case must exit with an error and launch nothing.
| When | Test | How | Pass |
|---|---|---|---|
| Run | Values load as the app expects | Quotes, escapes, export, multi-line, interpolation, empty values, comments | Exact values in the child |
| Run | Precedence is unchanged | A variable set in the shell and in the file | Same winner as before |
| Run | No plaintext fallback | Leftover .env with an extra key, app calls its own loader | Extra key absent |
| Run | Arguments survive | Spaces, quotes, $HOME, --, empty strings | Identical argv |
| Run | Exit status passes through | Child exits 37; child kills itself with SIGTERM | 37; killed by SIGTERM |
| Run | Signals reach the app | SIGTERM and Ctrl-C sent to the wrapper’s process | App handles them |
| Fail | Tampered ciphertext | One changed byte, a truncated file, a non-age file | Refused, nothing launched |
| Fail | Wrong key | Vault returns a different key, or something that is not a key | Refused, nothing launched |
| Fail | Denied Touch ID | Mock op refuses, plaintext .env present | Refused, no fallback |
| Fail | Missing encrypted file | Only plaintext .env exists | Refused, no fallback |
| Fail | Unparseable config | A line the dotenv parser cannot read | Refused, line number only |
| Setup | Key never on a command line | Mock op argument log | Key absent |
| Setup | Never overwrites | Existing item title; existing local config | Refused, vault unchanged |
| Setup | Desktop auth only | Service-account token set in the shell | Not passed to op |
| Migrate | Byte-exact round trip | Decrypt the new file with the dummy key | Identical bytes |
| Migrate | Refuses to overwrite | Existing .env.enc; with the explicit flag | Kept; backup made |
| Update | Edits round-trip | set and unset with quotes, backslashes, newlines | Exact values back |
| All | No leaks, no leftovers | All output searched for dummy values; temp folders scanned | Nothing found |
Failure modes
What can go wrong, and the fix.
Each of these came up while building and testing this workflow.
The agent decrypts your real secrets.
You approved 1Password a few minutes ago; the agent runs the start command to check its work, and the app starts with real values without a prompt. Forbid the decrypting commands against real files in the brief, and verify with dummy tests.
A leftover .env fills the gaps.
The framework or a dotenv call in the code reads plaintext for every key the encrypted file lacks. Disable every loader for the wrapped app and test it with an extra key that only the plaintext file has.
A bad line disappears silently.
Some dotenv parsers skip a line they cannot read and only log a warning, so a secret goes missing without an error. The wrapper checks for parse errors and refuses, reporting the line number only.
A value with ${ changes.
Many dotenv libraries expand ${VAR}, sometimes even inside single quotes. The set command refuses such values instead of storing something that will load differently.
Pipes break after exec.
Some runtimes ignore SIGPIPE, and ignored signals survive exec into the app, so a command piped into head complains instead of exiting quietly. Restore default signal handling before exec.
Test runs leave dummy keys behind.
A test that fails during setup can skip its cleanup and leave temp folders with dummy keys. Register cleanup immediately after creating the folder, and scan for leftovers at the end.
The encrypted file is lost.
.env.enc is git-ignored, so it lives on one disk. Keep a copy somewhere safe, for example as a document in the same vault. Backups of ciphertext are harmless.
The vault item is deleted.
Without the private key nothing can be decrypted, by design. Restore it from Recently Deleted, and write the recovery steps down before you need them.
A token overrides Touch ID.
A service-account or Connect token in the shell silently replaces desktop authentication. The wrapper strips those variables from every call it makes to op.
The prompt
Hand this to your coding agent.
Fill in the parts in square brackets. The agent builds and tests with dummy data only, then stops and gives you the commands to run yourself.
Implement encrypted local environment-file support for this project using age and the 1Password CLI. My machine: [macOS / Linux / Windows] with age, the 1Password CLI and the 1Password desktop app installed, and "Integrate with 1Password CLI" turned on in the app's Developer settings. Target vault: [Development]. How I start the app today: [e.g. the dev server command]. Other commands that need the variables: [worker, tests, none]. THIS PHASE IS IMPLEMENTATION AND DUMMY-DATA TESTING ONLY. - Do not read, change, encrypt, delete or display the real .env (or any .env.* file), and do not retrieve existing vault secrets. - Do not run the setup, migrate, run, update or plaintext-removal commands against this project's real files, now or later. My 1Password session may already be authorised in this terminal, so they would decrypt real secrets without any prompt. Verify with the dummy-data tests only. INSPECT FIRST (without opening secret files) Inspect the project's language and runtime, every place that loads a dotenv file (library calls in the code and the framework's own loader), the current precedence between shell variables and the file, the development commands, and anything that inlines environment variables into browser bundles or templates. Consult the current official documentation for age, the 1Password CLI and the dotenv/framework versions in use. Build .env.example from the variable names the code reads, with placeholder values only. Report what you found and your plan, then continue. BUILD THESE WORKFLOWS in this project's language and tooling, as one small command-line wrapper: 1. Setup: generate a dedicated age key pair for this project with its output captured. Store the private identity in a concealed field of a new 1Password item in the target vault, by piping a JSON item template to `op item create -` on standard input (never as assignment arguments). Read it back and compare. Keep only the public recipient and the non-secret vault/item IDs locally. Never overwrite an existing item or local config. 2. Migration: encrypt the existing .env into .env.enc using the public recipient. Retrieve the private identity from 1Password and verify that decryption exactly matches the original bytes, in memory, without displaying them. Refuse a .env the dotenv parser cannot fully parse. Use atomic writes, and do not overwrite an existing encrypted file without an explicit flag that keeps a backup. Plaintext deletion is a separate, explicit command that first verifies .env.enc decrypts to exactly the current .env. 3. Run: authenticate through the 1Password desktop integration, retrieve the private identity, decrypt .env.enc, parse it with the same dotenv library the app uses (never source, eval or execute it), and launch the requested command with those variables in its environment. With no command, launch the default dev command. Preserve the current precedence, arguments, exit status and signals (prefer replacing the wrapper process with exec; restore any signal dispositions the runtime ignores, such as SIGPIPE, before exec). Make sure no dotenv loader in the app or framework can read a leftover plaintext .env when launched this way (for example a disable variable such as PYTHON_DOTENV_DISABLED or FLASK_SKIP_DOTENV, or removing --env-file). 4. Updates: provide `set KEY` (value from a hidden prompt or standard input, never an argument), `unset KEY`, and `keys` (names only). Each update re-encrypts, verifies the round trip and keeps a timestamped backup of the previous ciphertext. Refuse values the dotenv library would interpolate. Full-file editing through a temporary plaintext file must be an explicit opt-in flag: owner-only file in an owner-only temp directory, removed on success, error and interrupt; document editor swap/backup files and cleanup limits. 5. Status: report leftover plaintext dotenv files, stale temp directories and whether git ignores the secret files, without decrypting anything. SECURITY REQUIREMENTS - Never display real credentials or private keys in tool output, chat, logs, exceptions, diffs or test reports. Print errors without tracebacks that could include decrypted data; redact anything key-shaped from subprocess errors. - Keep private keys and decrypted configuration out of command-line arguments, shell history, persistent environment configuration and plaintext files. Pass the identity to age through standard input, an anonymous pipe (/dev/fd) or a library binding. - Capture sensitive subprocess output internally. Key generation and vault-item creation must not print secrets. - Use the desktop-app integration only: remove OP_SERVICE_ACCOUNT_TOKEN, OP_CONNECT_HOST, OP_CONNECT_TOKEN and OP_SESSION_* from the environment of every op call. - Fail closed on authentication, retrieval, decryption or parsing errors, and when .env.enc is missing. No fallback to plaintext .env. - Do not cache the decrypted key or values between launches. - Make sure framework start-up does not overwrite injected values, and that no secret uses a browser-exposed prefix. - Git-ignore plaintext dotenv files, .env.enc and its backups, private-key files and temporary secret directories. Keep a placeholder-only .env.example tracked. - Do not modify production or CI secret handling. Use no custom cryptography. TESTS (dummy values only) Use real age with throwaway keys and a mock op placed first on PATH that serves a dummy key, logs its arguments and can simulate denied authentication. Cover: successful loading; dotenv quoting, escapes and multi-line values; variable precedence; a leftover plaintext .env being ignored; arguments, exit status and signal handling; malformed, truncated and tampered ciphertext; a wrong key; denied authentication; unparseable config; setup never passing the key as an argument and never overwriting; migration round trip and overwrite refusal; set/unset round trips; the opt-in editor flow including cleanup on interrupt. Check every output for dummy secrets, and check for leftover plaintext files and temp directories. Register test cleanup immediately after creating each temp directory. Then break each protection once in a scratch copy and confirm a test fails. DOCUMENT exact project-specific commands for setup, migration, running each dev command, updating secrets, plaintext removal and recovery (lost local config, corrupted or lost .env.enc, deleted vault item, compromised key). Document that 1Password approval covers the terminal session for a limited time, and that locking the vault does not remove secrets from an already-running application. FINISH by showing the files changed, the tests performed with their results, and the exact commands I will run next. Stop before generating the real project key or migrating the real .env.
Works with any agent that can run shell commands: Claude Code, Codex, Cursor and the like. Run it with command approval switched on, and keep 1Password locked while the agent works.
Questions
The short answers.
My .env is git-ignored. Is that not enough?
Git-ignoring protects you from one leak, committing the file. It does nothing against everything else that can read your disk: install scripts in packages you add, AI tools and editors that index the project folder, cloud sync of your Desktop or Documents, backups, and anyone who copies the folder to share a project. Each of these reads plaintext. With an encrypted file they read ciphertext.
Why age and not GPG, or a vault-only tool?
age is small, has no configuration, and encrypts to a short public key, so encrypting never needs the secret. GPG works too but brings keyrings, trust models and agent caching you would have to reason about. 1Password also has its own tools that inject secrets at runtime, and they are a good choice if you are happy to store every variable as a vault item. This tutorial keeps your file format: one encrypted .env per project, and one key in the vault.
Does this change production?
No. Your hosting platform keeps injecting its own variables, and production never has a .env file to decrypt. The wrapper is a local development tool. The prompt tells the agent not to touch production or CI secret handling.
Do I have to approve Touch ID every time I start the app?
No. When you approve, 1Password authorises the CLI for that terminal session and its sub-shells. The authorisation expires after 10 minutes without CLI use and after 12 hours at most. Locking the 1Password app revokes it at once.
What if the 1Password item is deleted?
Then every file encrypted to that key is unreadable, by design. There is no second copy of the private key. Restore the item from Recently Deleted in 1Password, or rebuild the values from your providers. This is why the tutorial asks the agent to write recovery steps, and why you should keep a copy of the encrypted file itself somewhere safe.
Can my coding agent still run the app?
Technically yes, and that is the trap. If you approved 1Password in a terminal a few minutes ago and the agent runs the start command in that terminal, it decrypts real secrets without a prompt. The prompt below forbids the agent from running the decrypting commands against your real files, and has it test with dummy keys and a mock instead.
Does this work on Windows and Linux?
The approach does. age and the 1Password CLI run on macOS, Windows and Linux, and the desktop integration works on all three. The detail that differs is how the key is handed to age in memory: an anonymous pipe through /dev/fd on macOS and Linux, standard input or a library binding elsewhere. The agent picks the mechanism for your platform.
Sources
- age · Simple, modern file encryption
- 1Password CLI · Desktop app integration
- 1Password CLI · App integration security
- 1Password CLI · Create items
- 1Password · Create and share vaults
- Next.js · Environment variables
- Vite · Env variables and modes
- Node.js · --env-file
- Nx · s1ngularity postmortem, August 2025
- CISA · Shai-Hulud npm compromise, September 2025
- OWASP · Secrets Management Cheat Sheet