What you'll learn
- Why a secret that ever touched a commit is public, and why rotating it is the only real fix
- How frameworks block XSS and SQL injection by default, and which escape hatches turn that protection off
- Why password storage is never something you build yourself, and what session cookies and tokens each do
- How to read npm audit output without panic or autopilot
- Why authorization in client-side JavaScript protects nothing
on this page
Your app works. The build passes, the demo flows, and you are one push away from a URL you can hand to strangers. Before that push, do one security pass, because the gap between “strangers can see my app” and “strangers can read my database” is usually about an hour of unglamorous checking.
The pass is a short list of questions. None of them require a security background, and most of the fixes take minutes. If you have not shipped anything yet, How localhost becomes a real website covers the mechanics; this guide assumes you are about to do it for real.
Are there secrets in your git history?
A secret is anything that lets a stranger act as you: API keys, database connection strings, signing secrets. The standard setup is to keep them in a .env file, list that file in .gitignore, and give your hosting provider the values through its dashboard instead of through the repo.
# .gitignore
.env
.env.local
.env.*.local
The part nobody tells you: .gitignore only prevents future commits. If a key was ever committed, it lives in your history, and a later commit that deletes the line changes nothing. Git is built to remember. Anyone who clones the repo gets every version of every file you ever committed, including the one with the key in it.
On a public repo this is worse than it sounds, because nobody has to go looking. Bots continuously scan GitHub for strings shaped like credentials (AWS keys, Stripe keys, database URLs), and a leaked key gets tried fast. Minutes, not days. The scraping is automated, so it does not care that your project is small.
So the secrets rule has three parts: none in the repo, none in the client bundle (next question), and rotated if ever exposed. Rotation means going to the provider’s dashboard, revoking the old key, issuing a new one, and updating the value where your host stores it. If a key has ever appeared in a commit, treat it as public and rotate it. Today, before the deploy.
The secrets management guide goes deeper on the day-to-day mechanics. For the pre-ship pass, the question is just: has any real key ever touched a commit, and if so, has it been rotated since?
Is anything secret in the client bundle?
The second leak path is sneakier because nothing about it feels like publishing. Every value your frontend code touches ends up in the JavaScript files served to visitors. Write const key = process.env.STRIPE_SECRET in a component, and that key is sitting in your bundle where anyone can read it with the browser’s dev tools or a single download.
Frameworks try to protect you with naming prefixes: PUBLIC_ in Astro, NEXT_PUBLIC_ in Next.js, VITE_ in Vite. Only prefixed variables are exposed to client code; everything else stays server-side or fails to resolve. The prefix is a contract that says “this value is fine to publish.” The moment you find yourself prefixing something to make an error go away, stop and ask whether you just agreed to publish a secret.
Some keys are public by design. Stripe’s publishable key (pk_live_...), a maps key locked to your domain, Supabase’s anon key: these are built to live in browsers. The test is what the key can do, never where the dashboard displays it. If a key can spend money, read private data, or delete things, it cannot live in frontend code. The call it authorizes has to move to a server that holds the key, and the browser talks to that server instead.
The whole sweep takes two minutes:
git ls-files '*.env*' Lists tracked env files. Empty output is the pass. If a .env shows up here, it is in the repo, and everything inside it is in your history.
git log --all --oneline -S sk_live Searches every commit ever made for a Stripe-style key. Swap in the prefixes your providers use: AKIA for AWS, ghp_ for GitHub, sk_live for Stripe.
grep -r sk_live dist/ dist/assets/index-BfKx2a.js:{apiKey:"sk_live_51M... Run after a build. Empty output is a pass. A hit like this one means the key ships to every visitor: rotate it and move the call to a server.
What happens to the stuff users type?
Everything a user types into your app eventually lands in one of two places: rendered back into your HTML, or stored in your database. Each destination has a classic attack, and both attacks have the same shape: text you treated as data gets executed as code.
The HTML version is XSS (cross-site scripting). Someone submits a comment containing a <script> tag, your page renders it as real markup, and now their code runs in every visitor’s browser with access to whatever the page can touch. The browser has no way to tell your script from theirs.
Here is the part that should lower your blood pressure: modern frameworks escape output by default. When React, Vue, Svelte, or Astro render a value through normal interpolation, angle brackets become harmless text. The pasted script tag displays as characters instead of running.
// Fine: React escapes this, a pasted <script> renders as text
<p>{comment.body}</p>
// Not fine: this executes whatever the user typed
<p dangerouslySetInnerHTML={{ __html: comment.body }} />
The danger lives in the escape hatches: dangerouslySetInnerHTML in React, v-html in Vue, set:html in Astro, @html in Svelte, raw innerHTML anywhere. The names are the documentation. The rule for your pass is simple: user input never goes through one of those. If you genuinely need users to write rich text, run it through a maintained sanitizer like DOMPurify before rendering. Do not write your own regex filter; attackers have a multi-decade head start against homemade regex filters.
The database version is SQL injection, and it is the same mistake in a different room:
// String-built: the user's text becomes part of the command
await db.query(`SELECT * FROM users WHERE email = '${email}'`);
// Parameterized: the user's text is only ever a value
await db.query("SELECT * FROM users WHERE email = $1", [email]);
In the first version, an email of ' OR '1'='1 rewrites the query’s logic, because the database cannot tell where your command ends and the user’s text begins. Parameterized queries send the command and the values through separate channels, so the database never confuses one for the other. ORMs and query builders (Prisma, Drizzle, Kysely) parameterize everything for you, and the risk reappears exactly where you would expect: the raw escape hatches, like Prisma’s $queryRawUnsafe or a builder’s sql.raw. Same pattern as the frontend. The defaults are safe; the functions with warning labels in their names are where the bugs live.
Who is handling the passwords?
This one has the shortest answer on the list: never hand-roll password storage. Doing it correctly involves slow salted hashing (bcrypt or argon2, never MD5 or plain SHA-256), rate limiting, and a reset flow that does not leak which emails exist. Every piece is easy to get subtly wrong, and the wrongness stays invisible until someone dumps your users table and cracks it offline at billions of guesses per second on rented GPUs.
So use something maintained. In rough order of how little you have to build: the auth built into your platform (Supabase, Firebase), a hosted provider (Clerk, Auth0), or a library wired into your framework (Auth.js is the common pick in the Next.js world). If a course assignment forces you to store passwords yourself, use the bcrypt or argon2 package directly and copy nothing from an old blog post.
Whatever you pick, you will run into two words worth understanding, because they describe how the server recognizes a logged-in user on the next request:
- A session cookie means the server keeps a record of who is logged in, and the browser holds only an opaque ID in a cookie it sends automatically with every request. The server looks the ID up each time. Logging someone out, or banning them, is instant: delete the record.
- A token (usually a JWT, JSON Web Token) means the server signs a small blob stating who you are, and the client sends it back with each request. The server verifies the signature instead of looking anything up, which is convenient across multiple services, but a stolen or stale token stays valid until it expires, so revoking one early is awkward.
For a first real app you do not pick between these; your auth library’s default is right, and it is usually cookie-based sessions. The point is to recognize the words when they show up in the docs.
What does npm audit say?
npm audit
This checks your installed packages against a public database of known vulnerabilities and reports findings by severity. Run it before you ship, then avoid the two standard failure modes: panic and autopilot.
Panic looks like seeing a wall of “high severity” findings and assuming the app is compromised. Read where each finding lives first. A vulnerability in a build-time tool runs on your laptop during npm run build and never ships to users; a vulnerability in a package your server runs in production is the kind that matters. Same label, very different urgency.
Autopilot looks like running fixes without reading them. Plain npm audit fix applies only updates that respect your version ranges, which is generally safe. npm audit fix --force will install breaking major versions, or downgrade packages, to make the report go green, and it can quietly wreck a working app in the process. If the report points at a real production dependency, update it deliberately and rerun your tests, rather than letting a flag negotiate your versions for you.
Is HTTPS actually on?
Probably yes, and you should still check. Modern hosts (Vercel, Netlify, Cloudflare Pages) issue and renew certificates automatically, so the encryption itself is handled. Your pass is two quick verifications.
First, the plain http:// version of your URL should redirect to https://:
curl -I http://your-site.com
# Look for a 301 with a Location header pointing at https://
Hosts do this by default, but a misconfigured custom domain can leave the plain version answering directly, which means anything sent to it travels unencrypted.
Second, no mixed content: an https:// page loading a script or image over http://. Browsers block or strip those requests, so this shows up as a broken image plus a console warning. The cause is almost always a hardcoded http:// URL in your code; make it https:// and move on.
What a static site cannot enforce
If your deploy is static files plus client-side JavaScript, here is the boundary you need burned in before shipping: all of that JavaScript runs on the visitor’s machine, under the visitor’s control. They can open dev tools, edit any variable, and replay any request your code makes, with any payload they like.
Which means a check like this:
// Hides the button. Protects nothing.
if (!user.isAdmin) return null;
controls what your UI displays, never what a visitor can do. The “hidden” page is still in the bundle they downloaded. The fetch your admin code makes can be made by anyone with curl. Anything that looks like authorization in client JS is decoration, and a mildly curious visitor walks through it in about a minute.
Real enforcement needs code the visitor cannot edit, which means a server: an API route, a serverless function, anything that checks who is asking before it answers. This is also why services built for browser access still work. Supabase publishes its anon key on purpose, but only because row-level security rules, enforced on their servers, decide what that key can do. The key is public because the rules are supposed to be doing the work. If you wired up Supabase and never wrote the rules, nothing is doing the work.
None of this means your project needs a backend. A portfolio site with no accounts and no private data has nothing to authorize, and static is the correct architecture for it. The case to catch is the middle one: a static frontend talking straight to a database or a paid API from the browser. The moment your app has a rule about who can do what, that rule needs a server to live on, or it is not a rule.
How far this pass gets you
Be honest about what you just bought. This pass stops the embarrassing 90 percent: keys scraped by bots, the comment box that executes JavaScript, plaintext passwords in a dumped table, the admin page guarded by a hidden button. Those failures happen automatically, at scale, to whoever leaves the door open, and the bots running them do not know or care that your app is a portfolio piece.
A determined human attacker studying your app specifically is a different problem with a different price tag, and a portfolio project is not worth their evening. That asymmetry is the entire economics here: an hour of checking removes you from the pool of automatic victims, and the expensive kind of attacker has no reason to show up. For what you are shipping, that trade is fine.
So run the questions against the actual project. List the tracked env files, grep the built bundle for your key prefixes, type <script>alert(1)</script> into your own signup form and confirm it renders as text, read the audit output, load the http:// version of your URL and watch it redirect. Twenty minutes on a project you know well. Then push it, hand out the link, and when something eventually misbehaves in production, the trail it leaves is the next skill, which is where the next guide picks up.
signal check
Can you answer these without scrolling back up?
-
You committed an API key last week and deleted it in a later commit. Are you safe?
No. Git keeps every committed version of every file, so the key is still in your history, and on a public repo scrapers may already have it. Treat it as public: revoke the key in the provider's dashboard, issue a new one, and update the value in your host's environment settings. Rotation is the fix; deleting the commit is housekeeping.
-
React escapes output by default, so why is dangerouslySetInnerHTML a problem?
Normal JSX interpolation turns user input into plain text, so a pasted script tag just displays as characters. dangerouslySetInnerHTML skips that escaping and injects raw HTML, which means anything executable in the input runs in your visitors' browsers. Never pass user input through it; if you need user-written rich text, sanitize with a maintained library like DOMPurify first.
-
Your static site hides the admin panel unless isAdmin is true in client JS. What is that check protecting?
Nothing. The check runs on the visitor's machine, where they can edit the variable in dev tools or skip your UI entirely and call your data endpoints with curl. Client-side checks control what the page shows, never what a visitor can do. Real authorization needs a server that verifies who is asking before it answers.
progress is stored in this browser only