Skip to content

8.1 Secret Management and Environment Variables

Goal of this section: Learn how to use environment variables to manage secrets, understand the security boundary between Server and Client, and make sure sensitive information never appears in your code or Git history.


Xiaoming's Lesson

In the preface, Xiaoming pushed an API key to GitHub, and it was abused for $127 within 5 minutes. But the story actually started earlier.

When Xiaoming first wrote the code, his database connection looked like this:

typescript
const db = drizzle('postgresql://ming:mypassword123@db.example.com:5432/moviedb')

And his AI key looked like this:

typescript
const openai = new OpenAI({ apiKey: 'sk-proj-abc123...' })

He thought this was pretty convenient—just write it directly in the code so there was no need to look for it elsewhere. Everything worked fine locally, and nothing had gone wrong yet. It wasn't until he pushed the code to GitHub and the key was abused that he finally understood one thing: code is meant to be shared, secrets are not. Writing secrets directly in code is like writing your bank card PIN on the back of the card—as soon as someone sees your card, the PIN is exposed.

The "secrets" we're talking about here include a lot of things: database connection strings like (DATABASE_URL) hide the database username and password, third-party API keys like (OPENAI_API_KEY) are your credentials for calling AI services, OAuth secrets like (GITHUB_CLIENT_SECRET) are the trust credentials between you and GitHub, payment keys like (STRIPE_SECRET_KEY) are directly tied to your receiving account, and auth secrets like (BETTER_AUTH_SECRET) are used to sign user sessions. What they all have in common is this: once they're leaked, the best-case scenario is someone burns through your quota; the worst-case scenario is all user data gets exposed. And these secrets are often connected to each other—if the database password leaks, the user table is exposed; if the user table is exposed, everyone's email addresses and password hashes are gone too.

The solution is environment variables—pull the secrets out of the code and put them in a file that won't be committed. In your code, you only say "read the secret from this place" instead of writing the secret itself.

From hardcoding to .env

The first thing the veteran developer taught Xiaoming was this: create a .env file in the project root and put all secrets there in one place:

DATABASE_URL=postgresql://user:password@host:5432/mydb
OPENAI_API_KEY=sk-proj-xxxxx
BETTER_AUTH_SECRET=your-random-secret-here

Then, instead of hardcoding secrets in the code, read them through process.env.DATABASE_URL—just like a mobile app doesn't hardcode screen brightness, but reads the current value from "system settings." process.env is Node.js's version of "system settings": it automatically reads all key-value pairs from the .env file. This cleanly separates secrets from code—your code can safely be pushed to GitHub, while the secrets stay in your local .env file. It's like moving your house key from under the doormat (written in the code) into a safe (stored in .env). Anyone can check under the doormat; at least a safe requires a password to open.

But having .env alone isn't enough. Xiaoming created the .env file, moved all his secrets into it, and thought the problem was solved. Then he habitually ran git add .—and that command adds all files in the current directory to Git staging, including .env. He didn't notice, and went straight to git commit and git push. The secrets went up again. His reaction was basically: I already took the secrets out of the code—how did they still leak?

The veteran developer pointed at the .gitignore file in the project root and said, "This is the last line of defense keeping you from going broke." .gitignore is Git's ignore list—anything listed in this file gets automatically skipped when Git scans for changes, and git add . won't include it either. You can think of it as a blacklist: once a file is on the list, Git acts like it doesn't exist. Check whether your .gitignore includes the following:

gitignore
# Sensitive config
.env
.env.local
.env.production

# Dependencies (too large, teammates install them locally)
node_modules/

# Build output (temporary files generated by compilation)
.next/
dist/

# System junk
.DS_Store
Thumbs.db

If a file was already committed, adding it to .gitignore won't help

If .env has already been tracked by Git (committed before), simply adding it to .gitignore will not make it disappear. You need to first run git rm --cached .env to remove it from the index, then commit again. More importantly, the secret can still be seen in Git history—tell AI: "I accidentally committed a secret into Git history. Help me scrub it." After cleaning it up, make sure to rotate the secret on the corresponding platform.

.env.example—leave a template for your future self

A month later, Xiaoming switched to a new computer. He pulled the code from GitHub, ran pnpm install, then pnpm dev—and got a bunch of errors. DATABASE_URL undefined, OPENAI_API_KEY undefined... Only then did he remember: .env was ignored, so the new machine didn't have that file at all. Even more awkwardly, he could no longer clearly remember which environment variables the project needed. The project had started three months ago, and in the meantime he'd added AI features, authentication, and third-party OAuth. Every time he added something, he stuffed a new variable into .env, but he never documented any of them. Search the code for process.env. references? Too much work—and he might still miss some.

The veteran developer said, "You should include a .env.example file in the project." This file lists all variable names but leaves the values blank:

DATABASE_URL=
OPENAI_API_KEY=
BETTER_AUTH_SECRET=
NEXT_PUBLIC_APP_URL=

.env.example should be committed to Git. It's like a blank form—anyone who gets the project can copy it, rename it to .env, fill in their own values, and run the project. If .env is the safe containing the real keys, then .env.example is an empty key mold that tells you how many keys you need and what kind they are.

Tell AI: "Generate a .env.example file for me based on all environment variables used in the project."

Server vs Client—the easiest trap to fall into

Xiaoming put API_KEY=sk-proj-xxx in .env. In an API Route (server-side code), console.log(process.env.API_KEY) printed just fine. But he wanted to show an "AI feature enabled" badge on the frontend page, so he tried reading the same variable in a React component—and got undefined. He checked the spelling three times and suspected a bug. Then he tried console.log(process.env) and realized the environment variables available on the frontend didn't include API_KEY at all.

He threw the question to AI, and AI explained: this isn't a bug—it's a security mechanism in Next.js.

Imagine this: your backend code runs on your own server, like a bank vault—only internal staff can get in. But frontend code is ultimately downloaded and run in the user's browser, like the bank lobby—everyone can see it. If you put the vault password on the wall in the lobby, the vault might as well not exist.

Server code runs on a cloud server or in your local Node.js process. What users see is only the result returned by the server (HTML, JSON); they cannot directly access the server's internal code or variables. So reading secrets there is safe—by default, variables in .env are only available on the server side. Client code runs in the user's browser. Your React components, CSS, and JavaScript are all bundled and sent to the user's browser in the end. Users can press F12 to open DevTools and see all the code and data sent to the browser. If a secret is sent to the client, that's the same as taping the safe combination to the front door—anyone can see it.

So the Next.js rule is simple: only variables prefixed with NEXT_PUBLIC_ get bundled and sent to the browser. This prefix is like a "checked baggage" tag on luggage—if the tag isn't there, the airline won't load it onto the plane; if the prefix isn't there, Next.js won't send it to the browser. For example, DATABASE_URL doesn't have this prefix, so it's only available on the Server, which makes it suitable for database passwords, API secrets, and other sensitive information. On the other hand, NEXT_PUBLIC_APP_URL does have the prefix, so both Server and Client can read it, making it suitable for public information like your website URL or analytics IDs. This prefix is basically a declaration that says, "I confirm this value can be public"—only when Next.js sees this prefix will it bundle the variable's value into the frontend code. The rule of thumb is simple too: ask yourself, if a user saw this value in the browser via F12, would there be a security risk? If yes, don't add NEXT_PUBLIC_; keep it server-only.

🔒服务端 (安全)
DATABASE_URL=postgres://user:pass@host/db
AUTH_SECRET=super-secret-key
STRIPE_SECRET_KEY=sk_live_...
安全边界
🌐客户端 (公开)
NEXT_PUBLIC_APP_URL=https://myapp.com
NEXT_PUBLIC_STRIPE_KEY=pk_live_...
服务端变量客户端代码
泄露风险:在客户端代码中使用服务端变量会导致密钥暴露
规则NEXT_PUBLIC_ 前缀 = 客户端可见,不带前缀 = 仅服务端

Changed config but nothing happened? Restart first

Xiaoming changed the database password in .env (because the old one had leaked and he reset it), refreshed the page, and still got "connection failed." He changed it again—still no luck. He started wondering whether the .env file itself was broken. The veteran developer calmly asked, "Did you restart?"

Xiaoming held back tears, hit Ctrl+C to stop the dev server, ran pnpm dev again, and everything worked.

The reason is simple: environment variables are loaded into memory when the program starts. While the program is running, it reads the old values in memory and won't automatically check whether .env has changed. It's like checking the weather forecast before you leave home and seeing sunshine—if the weather changes after you go out, you won't know unless you check again. Almost everyone falls into this trap at least once. For 90% of .env issues, restarting fixes it.

From local to the cloud

Xiaoming's app was finally ready to be deployed. He pushed the code to Vercel, clicked the deploy button, and eagerly waited to see it live. As a result, everything broke—the database wouldn't connect, the AI API failed, and authentication stopped working. Only then did he realize the issue he'd overlooked: local development used a .env file, but .env was ignored by .gitignore, so it never got pushed to GitHub. Naturally, the Vercel server didn't have that file either. When the code ran there, process.env.DATABASE_URL was undefined.

Every deployment platform (Vercel, Railway, Cloudflare, etc.) has a dedicated environment variable settings page. You need to enter everything from your local .env there one by one—both variable names and values, like filling out an online form again. It's like moving money from the safe in your house (local .env) to a bank vault (cloud config)—the location changes, but the essence stays the same: the secrets live in a secure place, and the code reads them through process.env. One thing to watch out for: some values differ between local and production. For example, DATABASE_URL might point to localhost locally, but in production it should point to your cloud database address; BETTER_AUTH_URL might be http://localhost:3000 locally, but in production it should be changed to your domain.

The same principle applies if you're using MCP servers (such as Supabase MCP or GitHub MCP). Their connection info also contains sensitive credentials, so they should also be managed through environment variables rather than hardcoded in config files.

Tell AI: "Check my project to make sure all secrets are managed through environment variables. Generate .gitignore and .env.example files. If any secrets are hardcoded in the code, help me change them to read from environment variables."


Key takeaways from this section

  • Put secrets in .env, and read them in code with process.env.XXX
  • Use .gitignore to ignore .env so it doesn't get committed to Git
  • .env.example lists variable names without values, and should be committed to Git as a template
  • Variables without the NEXT_PUBLIC_ prefix are only available on the server side—this is a security mechanism, not a bug
  • After changing .env, restart the service
  • In deployment, configure environment variables in the platform's settings page instead of uploading the .env file

Next up

Now that your secrets are under control, move on to Authentication Methods and Choosing an Approach—learn the differences between Session, Token, OAuth, and other auth methods, and how to choose the right auth library.

Alpha Preview:This is an early internal build. Some chapters are still incomplete and issues may exist. Feedback is very welcome on GitHub.