Security for Developers

Secrets, Encryption and Data Protection

Where secrets leak (git, logs, front-end bundles), hashing vs encryption vs encoding, TLS in plain words, safe randomness, encrypting data with AES-GCM, and handling personal data.

Lesson 0515 min read#security#secrets#cryptography#tls#privacy

On this page

  1. Words used in this lesson
  2. Hashing, encoding, signing, encryption: four different jobs
  3. Encryption with AES-256-GCM
  4. Randomness: Math.random is not for secrets
  5. Where secrets leak
  6. A secret leaked. What now?
  7. Secret scanners
  8. TLS in plain words
  9. Personal data basics
  10. Try it
  11. Interview questions
  12. Key takeaways
  13. What's next

In one line: a secret is anything that gives power to whoever holds it, so keep it out of code, git, logs and the browser bundle; know the four tools (hash, encode, sign, encrypt) and which job each one does; use crypto.randomBytes for anything that must be unguessable; let TLS protect data in transit; and collect and keep less personal data.

Lesson 4 decided who may read what. This lesson is about the data itself and the keys that guard it. In the OWASP Top 10:2025 it covers A04 Cryptographic Failures, and it touches A02 Security Misconfiguration (a secret in the wrong place is a misconfiguration) and A09 Security Logging and Alerting Failures (what you write into logs).

We will run real code for each idea: the difference between hashing, encoding, signing and encryption, AES-256-GCM with a tamper test, Math.random against randomBytes, a secret that stays in git history after you delete the file, a toy scanner, a logging redactor, and a local TLS server. Then we cover what to do when a secret leaks, and how to treat personal data. Lesson 6 moves on to dependencies and the supply chain, where leaked tokens in CI come up again.

Related reading: Browser Lesson 7 (frontend security checklist), Backend Lesson 5 (logging and structured logs), and DevOps Lesson 14 (CI/CD, where secrets live in pipelines). For lesson 3's password hashing and tokens, see Lesson 3.

What I ran / checked. Node.js v22.22.0, Git 2.43.0 and OpenSSL 3.0.13 on Linux. I used only node:crypto, node:https, node:child_process and node:fs, no npm packages. The outputs are real, but random values (keys, tokens, UUIDs, IVs) change on every run. The Git history test used a throwaway repo and a fake key (sk_test_FAKE...). The TLS test used a self-signed certificate I made for localhost and a server and client on my own machine. Not run: the real gitleaks tool (it is not installed here, and I could not install it), GitHub secret scanning and push protection (they need GitHub), and a real Next.js build. My "bundle" demo is a toy string replace to show the idea, not Next.js itself. Sources I read: OWASP Cheat Sheet Series (Cryptographic Storage, Secrets Management, Transport Layer Security, Logging, User Privacy Protection, Password Storage), from the OWASP/CheatSheetSeries GitHub repository; the OWASP Top 10:2025 introduction from OWASP/Top10; the Node.js crypto API docs (randomBytes, randomUUID, createCipheriv, timingSafeEqual, scrypt); the Next.js environment variables guide that ships in this repo under node_modules/next/dist/docs (Next 16.3.5); and the gitleaks README on GitHub. For GitHub's push protection I only saw a web search summary of the GitHub docs page "About push protection", not the page itself, so treat my description of it as secondary. Could not reach: the live owasp.org, NIST and PortSwigger sites. The retention advice in the privacy section is general practice; I did not read a legal text, and this is not legal advice.

Words used in this lesson

  • A secret is a value that proves identity or gives access: a password, an API key, a database URL with a password in it, a private key, a signing secret.
  • Hashing turns data into a fixed fingerprint. It is one way and has no key.
  • Encoding changes the format of data (like base64) so it can travel as text. It hides nothing.
  • Signing / a MAC (message authentication code, such as HMAC) proves data came from someone with the key and was not changed. The data stays readable.
  • Encryption hides data so only someone with the key can read it. AES-256-GCM is a common choice that also detects changes.
  • A key is the secret input to encryption or signing. A nonce / IV is a number that must be new for every encryption with the same key.
  • A CSPRNG (cryptographically secure random number generator) makes random values that cannot be predicted. In Node, crypto.randomBytes.
  • Entropy is a measure of how unpredictable a value is.
  • Rotation means replacing a secret with a new one and turning the old one off.
  • TLS is the protocol under HTTPS. A certificate proves a server is who it says. HSTS tells browsers to always use HTTPS for your site.
  • PII (personally identifiable information) is data about a real person that can identify them: name, email, phone, address, IP, ID numbers.
  • Data minimisation means collecting and keeping only what you really need.
  • A secret scanner is a tool that searches code and history for things that look like keys.

Hashing, encoding, signing, encryption: four different jobs

People mix these up in interviews and in code. The question to ask is: what do I need?

ToolNeeds a key?Can you go back?JobExample
HashNoNoFingerprint, compare, detect changeSHA-256 of a file; password storage (with a slow hash, lesson 3)
EncodingNoYes, by anyoneMake bytes into textbase64, URL encoding, hex
Signing (HMAC)Yes (shared secret)No (it makes a tag)Prove origin and no tamperingSigned cookies, JWT HS256, webhook signatures
EncryptionYesYes, with the keyKeep data secretEncrypting a stored card token or a backup

Each one as a runnable line, using node:crypto:

JavaScript
import { createHash, createHmac, randomBytes } from "node:crypto";const msg = "pay 100 to bob";console.log("sha256   :", createHash("sha256").update(msg).digest("hex"));const enc = Buffer.from(msg).toString("base64");console.log("base64   :", enc, "->", Buffer.from(enc, "base64").toString());const key = randomBytes(32);const tag = createHmac("sha256", key).update(msg).digest("hex");console.log("hmac     :", tag);console.log("hmac of changed text differs:",  createHmac("sha256", key).update("pay 900 to bob").digest("hex") !== tag);

Real output:

TEXT
sha256   : 7ed7949646cf54c48ed7eb975c6fbab8683de72ba1aafee7794d395c4681952abase64   : cGF5IDEwMCB0byBib2I= -> pay 100 to bobhmac     : 85e544221f116f72f799290ec724cce90c674b8a9a9341e7b5367239dcba83d0hmac of changed text differs: true

(The HMAC value is different on each run because the key is random.)

How to read it:

  • The SHA-256 of the same text is always the same. There is nothing secret in it, and no way back.
  • base64 is not security. I turned pay 100 to bob into cGF5... and back with no key. "We base64 the password" and "the token is base64" are not protection. Lesson 3 showed that a JWT payload is just base64url.
  • The HMAC changed when one number in the text changed. If you share the key with a partner, they can check that a webhook really came from you. Compare tags with timingSafeEqual.

Common wrong choices:

  • Encrypting passwords (hash them instead; OWASP says encrypt only in rare edge cases).
  • Hashing something you must read back later, like an address you need to display (encrypt it, or store it plainly with good access control).
  • Using base64 or "obfuscation" to hide a secret in front-end code.
  • Writing your own "encryption" with XOR or a homemade scheme. Use the standard tools.

Encryption with AES-256-GCM

When you really must store something readable later (an access token for another service, a national ID), you can encrypt it. The OWASP Cryptographic Storage cheat sheet says: for symmetric encryption use AES with a key of at least 128 bits, ideally 256, and use an authenticated mode such as GCM. An authenticated mode both hides the data and detects changes. It says not to use ECB outside special cases.

Here is a small encrypt and decrypt with node:crypto:

JavaScript
import { randomBytes, createCipheriv, createDecipheriv } from "node:crypto";const aesKey = randomBytes(32);                    // 256-bit key (in real life: load from a secret store)function encrypt(plain) {  const iv = randomBytes(12);                      // new random 96-bit nonce EVERY time  const c = createCipheriv("aes-256-gcm", aesKey, iv);  const ct = Buffer.concat([c.update(plain, "utf8"), c.final()]);  return Buffer.concat([iv, c.getAuthTag(), ct]).toString("base64"); // iv(12) + tag(16) + ciphertext}function decrypt(blob) {  const b = Buffer.from(blob, "base64");  const d = createDecipheriv("aes-256-gcm", aesKey, b.subarray(0, 12));  d.setAuthTag(b.subarray(12, 28));  return Buffer.concat([d.update(b.subarray(28)), d.final()]).toString("utf8");}

Test it, including a tampered copy and a wrong key:

TEXT
encrypted: OJwJAtjJjArJTAU9ng1v93Ke1Lk22apKo4lnk2gI0Xkl4kxt3KFmQ7BqaLA=decrypted: card ending 4242same input again gives a different blob: truetampered  : Unsupported state or unable to authenticate datawrong key : Unsupported state or unable to authenticate data

Notes on this:

  • The stored value holds three parts: the IV, the authentication tag (16 bytes), and the ciphertext. The IV and tag are not secret. The key is.
  • Encrypting the same text twice gave different blobs, because each call uses a new IV.
  • I flipped one bit of the ciphertext. Decryption did not return garbage. It threw an error. That is the "authenticated" part of GCM. A wrong key also fails the same way. With old modes like CBC without a MAC, a change could silently produce wrong data.
  • Never reuse an IV with the same key in GCM. That can break the security. With random 96-bit IVs the chance is small for moderate volumes, and OWASP says to respect the mode's limits. For very large volumes use a library or a key management service that handles it.
  • Use the error as a signal: if decryption throws, treat the data as corrupt or attacked. Do not catch it and carry on with empty data.

The real problem is the key

Encryption just moves the problem: now you must protect the key. If the key sits in the same database or the same repo as the encrypted data, you have gained little. The OWASP cheat sheet suggests ideas such as:

  • Keep keys in a secret manager or key management service (KMS), separate from the data. Restrict who and what can read them.
  • Use two layers: a data key encrypts the data, and a separate key-encrypting key protects the data key. Then you can rotate the outer key without re-encrypting everything.
  • Generate keys with a secure random generator, never from a short human password or from Math.random.
  • Plan for rotation from the start: store a key version next to each encrypted value.

And the best advice in that cheat sheet, in my words: the safest sensitive data is the data you do not store. Do not keep full card numbers; use a payment provider's token. Do not keep an ID document you do not need.

Randomness: Math.random is not for secrets

Math.random() is built for speed and games. It is a pseudo-random generator: a formula that continues from a hidden starting state. If someone learns enough of the state, they can predict the next values. The OWASP Cryptographic Storage cheat sheet has a table that lists the unsafe functions per language, and for Node.js it names Math.random() as unsafe and crypto.randomBytes() as the safe choice. The sheet says secure randomness is needed for keys, IVs, session IDs, CSRF tokens and password reset tokens.

JavaScript
import { randomBytes, randomUUID, randomInt } from "node:crypto";console.log("Math.random token:", Math.random().toString(36).slice(2, 12), " <- NOT for secrets");console.log("randomBytes token:", randomBytes(32).toString("base64url"));console.log("randomUUID       :", randomUUID());console.log("randomInt 6-digit:", String(randomInt(0, 1_000_000)).padStart(6, "0"));
TEXT
Math.random token: z2xdyi120v  <- NOT for secretsrandomBytes token: 5orShNWuLnnWPSKpraSn6IsVQeK_l2JcgLWmJwSU25wrandomUUID       : a1e5c6b8-eaa6-48a7-a95d-39b825efc3c9randomInt 6-digit: 761315

The Math.random token is only about 10 characters from a weak source. The randomBytes(32) token has 256 bits from the operating system's secure generator.

To show why a formula-based generator is predictable, here is a toy generator (a small public PRNG, not V8's). The same starting seed always gives the same "random" number:

TEXT
toy PRNG, two runs with same seed: 0.428087 0.428087

If a token were built from a generator seeded by the clock, an attacker who knows the time roughly would have only a small number of seeds to try. (I am not claiming a specific attack on V8's Math.random. The rule is simply: for secrets, use the secure generator, which exists for this purpose.)

Practical choices in Node:

  • randomBytes(n): tokens, keys, salts, session IDs. Encode with base64url or hex.
  • randomUUID(): a random v4 UUID, handy for IDs. Do not treat it as an access control (lesson 4).
  • randomInt(min, max): an unbiased random integer, good for codes like the 6 digits above. Do not do Math.floor(Math.random()*1e6) for one-time codes.

Where secrets leak

Secrets rarely leak through a clever attack. They leak through ordinary places. Here are the common ones.

1. A committed .env file

.env files hold local secrets. If you commit one, it goes to every clone. The Next.js docs in this repo state it plainly: the default create-next-app template adds all .env files to .gitignore, and you almost never want to commit them. Check that your .gitignore has .env*.local (or .env) before your first commit.

2. Git history remembers

The most common mistake: someone commits .env, sees the problem, deletes it, and thinks it is fixed. I reproduced it in a throwaway repo with a fake key:

TEXT
# commit 1: .env contains the key# commit 2: "remove .env, add gitignore"--- file now:. .. .env .git .gitignore app.js--- history still has it:b5cfd4b remove .env, add gitignoref9e681b first commit+STRIPE_KEY=sk_test_FAKE0123456789abcdef

(The .env file is still on my disk, but it is no longer tracked. I removed it from the index with git rm --cached.) The key is still in commit f9e681b. Anyone who clones the repo can run git log -p and read it. Deleting a file only changes the latest snapshot. Rewriting history (with a tool like git filter-repo) can remove it from your copy, but forks, clones and caches may already have it. So the key must be treated as leaked, whatever you do to the history.

Also, .gitignore only affects files git is not yet tracking. Adding .env to .gitignore after you committed it does nothing about the old commit.

3. Secrets in the front-end bundle

Anything your JavaScript sends to the browser is public. Anyone can open DevTools and read it. In Next.js, the docs (the environment variables guide, version 16.3.5 in this repo) say that variables without the NEXT_PUBLIC_ prefix are available only in the Node.js environment, and variables with that prefix are inlined at build time into the JavaScript bundle sent to the browser, replacing process.env.NAME with the real value.

Here is a toy that does the same replacement, so you can see what it means:

JavaScript
const source = `const key = process.env.NEXT_PUBLIC_MAPS_KEY; const db = process.env.DATABASE_URL; fetch("/x?k=" + key);`;const env = { NEXT_PUBLIC_MAPS_KEY: "map-key-123", DATABASE_URL: "postgres://user:pw@db/prod" };const bundle = source.replace(/process\.env\.(NEXT_PUBLIC_\w+)/g, (_, n) => JSON.stringify(env[n]));console.log(bundle);
TEXT
const key = "map-key-123"; const db = process.env.DATABASE_URL; fetch("/x?k=" + key);

The NEXT_PUBLIC_ value is now written in the file the browser downloads. The other variable stays as a server-side lookup. (Again: a toy of the idea, not a real Next build.)

So the rule: put only public values behind NEXT_PUBLIC_, such as a public analytics ID or a map key that you have restricted by domain in the provider's dashboard. Never put a database URL, a signing secret, or a payment secret key behind that prefix. The same goes for any "VITE_", "REACT_APP_" or similar public prefix in other tools. If the browser must call a service that needs a secret, make the call from your server and keep the secret there.

"It's minified" and "it's base64" are not protection. A secret in a front-end bundle is a published secret. Also check source maps and mobile app packages, which can be unpacked by anyone.

4. Logs

Logs are copied to many places: log stores, dashboards, support tools, screenshots. The OWASP Logging cheat sheet lists things that should usually not be written to logs directly, and should instead be removed, masked, hashed or encrypted: access tokens, session IDs, passwords, connection strings, encryption keys, payment card data, sensitive personal data. A small redactor shows the habit:

JavaScript
const SENSITIVE = /^(password|token|authorization|cookie|secret|api[_-]?key|card(number)?)$/i;function redact(v) {  if (Array.isArray(v)) return v.map(redact);  if (v && typeof v === "object")    return Object.fromEntries(Object.entries(v).map(([k, x]) => [k, SENSITIVE.test(k) ? "[REDACTED]" : redact(x)]));  return v;}const req = { path: "/login", email: "a@example.com", password: "hunter2",              headers: { authorization: "Bearer abc.def.ghi", "user-agent": "curl" } };console.log("bad :", JSON.stringify(req));console.log("good:", JSON.stringify(redact(req)));
TEXT
bad : {"path":"/login","email":"a@example.com","password":"hunter2","headers":{"authorization":"Bearer abc.def.ghi","user-agent":"curl"}}good: {"path":"/login","email":"a@example.com","password":"[REDACTED]","headers":{"authorization":"[REDACTED]","user-agent":"curl"}}

A key-name list like this catches the obvious fields. It cannot catch a secret inside a free text message, so also avoid logging whole request bodies or whole objects. Many logging libraries have a built-in redact option; use it. See Backend Lesson 5.

5. Error messages and URLs

  • Error messages that show a stack trace, a SQL query, a file path or a connection string help attackers. Show users a short message and an error ID. Keep the detail in the server log.
  • URLs end up in browser history, server logs, proxy logs and Referer headers. Never put a token or password in a query string if you can avoid it. A reset token in a link is a special case: make it single use and short lived (lesson 3).
  • Error trackers and CI logs can capture environment variables. Do not print process.env. In CI, use the platform's secret store, and know that people with admin access can often read those secrets.

6. Other places

Screenshots, tickets and chat messages, Docker images with secrets baked in (ENV lines remain in the image layers), notebooks, backups, and shared screens in meetings. The OWASP Secrets Management cheat sheet's main idea is to centralize secrets in a secrets manager, give each person and service the least access, automate creation and rotation, give secrets an expiry where possible, and audit who read what.

A secret leaked. What now?

Do these in order. The first step is the one people skip.

  1. Revoke and rotate the secret now. Create a new key, deploy it, then disable the old one. Assume the leaked value is already copied. The OWASP Secrets Management cheat sheet says secrets that may be compromised must be revoked to restrict access. Rotation comes before any cleanup, because the history rewrite does nothing for copies that exist elsewhere.
  2. Check for use. Look at the provider's audit log or billing for the time since the leak: unexpected calls, new users, new servers, large bills.
  3. Limit the damage. If the key could read user data, follow your incident process (lesson 8) and think about whether users or authorities must be told.
  4. Remove it from the code and put the new value in your secret store or environment config.
  5. Clean history if you want (git filter-repo), and ask people to re-clone. This is tidy-up, not safety.
  6. Prevent a repeat: add a scanner (next section), update .gitignore, and use scoped, short-lived keys.

Do not "fix" a leak by only deleting the file or force-pushing. The old value is already out. Only rotation makes it useless.

Secret scanners

A secret scanner looks for text that matches known patterns of keys (an AWS key ID starts with AKIA, a private key block starts with -----BEGIN ... PRIVATE KEY-----) and for random-looking strings next to words like key or token. Two kinds of tools matter:

  • Gitleaks is an open source command-line tool. Its README describes it as a tool for detecting secrets like passwords, API keys and tokens in git repos, files and anything passed through stdin. Its README currently notes that Gitleaks is "feature complete", that future releases are security patches only, and that the author is shifting focus to a successor project. I did not run it. You run it by hand, in a pre-commit hook, or in CI, and it prints each finding with the rule that matched.
  • GitHub secret scanning and push protection. Secret scanning looks for known secret formats in repositories and alerts you. Push protection checks during git push and blocks the push if it finds a secret, before it lands. I read this only in a search summary of GitHub's docs, which also said that plans and repository types affect availability, and that a developer can bypass a block and the bypass creates an alert. Check GitHub's current docs for what your plan includes.

To show the idea without installing anything, here is my toy scanner. It reads the whole git log -p --all output and matches a few patterns on added lines. Real tools have hundreds of rules and entropy checks:

JavaScript
import { execSync } from "node:child_process";const rules = [["Stripe key", /sk_(live|test)_[0-9a-zA-Z]{10,}/],               ["AWS access key id", /AKIA[0-9A-Z]{16}/],               ["Private key", /-----BEGIN [A-Z ]*PRIVATE KEY-----/]];const patch = execSync("git log -p --all", { cwd: "leak", encoding: "utf8" });for (const line of patch.split("\n"))  if (line.startsWith("+")) for (const [name, re] of rules)    if (re.test(line)) console.log("FOUND", name, "->", line.slice(0, 40) + "...");
TEXT
FOUND Stripe key -> +STRIPE_KEY=sk_test_FAKE0123456789abcdef...

It found the key that I had "deleted", because it scans history, not just the latest files. That is the main reason to scan history and not only the working folder.

Scanners have false positives (a test fixture that looks like a key) and false negatives (a plain password with no pattern). They are a safety net. Good habits come first: no secrets in code, .gitignore set up, secrets in a secret store, and scoped keys.

TLS in plain words

TLS is what makes https:// safe. When your browser connects to a site over HTTPS, three things happen:

  1. The server proves who it is. It shows a certificate signed by an authority your browser trusts. This stops someone in the middle from pretending to be your bank.
  2. The two sides agree on a secret key during a short handshake. The data is then encrypted, so people on the same Wi-Fi or at the network provider cannot read it.
  3. The data is protected from changes. If someone alters bytes in transit, the receiver detects it.

HTTPS does not make your app safe from XSS, IDOR or SQL injection. It protects the pipe, not what is at the ends. It also does not hide that you connected to a site, only what was sent.

I ran a local HTTPS server with a self-signed certificate (openssl req -x509 ... for localhost, valid one day). The server sends an HSTS header. First a client that trusts nobody, then a client told to trust my certificate:

TEXT
client that trusts nobody  : { error: 'DEPTH_ZERO_SELF_SIGNED_CERT' }client that trusts this cert: {  body: 'hello over TLS',  hsts: 'max-age=63072000; includeSubDomains',  proto: 'TLSv1.3',  cipher: 'TLS_AES_256_GCM_SHA384'}
  • The first client refused to connect. The certificate was not signed by an authority it trusts. This is the check that protects users from impostors. Never "fix" this in production by turning off verification (rejectUnauthorized: false or NODE_TLS_REJECT_UNAUTHORIZED=0). That removes the one protection TLS gives. In local tests, trust the specific certificate like I did with ca.
  • The second connection used TLS 1.3 with an AES-256-GCM cipher suite. That is the same AES-GCM from earlier in this lesson, now used for the connection. The OWASP TLS cheat sheet says to default to TLS 1.3 and allow TLS 1.2 only for compatibility, and that TLS 1.0 and 1.1 are deprecated. Real sites get certificates from a certificate authority, often for free and automatically, from your host or from a free service such as Let's Encrypt (general knowledge, not run here).

HSTS

Suppose a user types bank.com without https://. The first request goes out as plain HTTP, and an attacker on the network can interfere before the redirect. HSTS (HTTP Strict Transport Security) is a response header, Strict-Transport-Security, that tells the browser: "for the next max-age seconds, always use HTTPS for this site, and do not let the user click through certificate warnings". The header above says 63,072,000 seconds (two years) and includes subdomains. Both OWASP's TLS and User Privacy Protection cheat sheets recommend HSTS.

Care points:

  • Send HSTS only over HTTPS, and make sure every subdomain really supports HTTPS before you add includeSubDomains. A long max-age is hard to take back.
  • Also redirect HTTP to HTTPS at the server, and set cookies with Secure (lesson 3).
  • Add Strict-Transport-Security with the other headers from Lesson 2.

Personal data basics

Passwords and keys are secrets. Personal data is the other thing you must handle with care: names, emails, phone numbers, addresses, IP addresses, birth dates, health or ID information. A leak harms real people and often brings legal duties. Laws differ by country (GDPR in Europe is the best known; I have not read legal texts for this lesson, so ask someone qualified for your case). These engineering habits are common to all of them:

  1. Collect less. For each field, ask: "what feature needs this?" If none, do not ask for it. Data you never collected can never leak. The OWASP Cryptographic Storage cheat sheet says the same about sensitive data: the best protection is not to store it at all.
  2. Keep it for a limited time. Decide how long you need each kind of data and delete it after that. A user who closes their account should have their data deleted or anonymised, except what you must keep by law. Build a delete job early. It is hard to add later. (General practice; not drawn from one source I read.)
  3. Do not put PII in logs, URLs or error messages. The OWASP Logging cheat sheet lists sensitive personal data and payment data as things to remove, mask, hash or encrypt before logging, and suggests de-identification such as deletion, scrambling or pseudonymisation when the person's identity is not needed. Even "non sensitive" data like emails and phone numbers should be treated with care in logs. Log a user ID, not an email.
  4. Limit who can read it. Use the access control from lesson 4. Staff tools should show only what the person's job needs, and reads of sensitive records should be logged.
  5. Protect it in transit and at rest. TLS for transit. For the most sensitive fields, application-level encryption (AES-GCM above) with keys kept away from the database. Encrypt backups too.
  6. Be honest with users. OWASP's User Privacy Protection cheat sheet has a section on honesty and transparency: say what you collect and why.
  7. Test data is not a loophole. Do not copy real production data to laptops or test servers. Use fake or masked data.
  8. Third parties count. Every analytics tool, error tracker and email service that receives user data is another place it can leak. Send them only what they need.

"We only store it encrypted" is not a full answer if the app server holds the key and decrypts it for every request, or if anyone with database access can also read the key. Say who can decrypt, and why.

Try it

  1. Create a throwaway git repo, commit a fake .env, delete it in a second commit, and find the value with git log -p -S yourvalue. Then explain why adding .env to .gitignore did not help.
  2. Change one byte of an AES-GCM blob and try to decrypt. Then swap the order of two bytes in the key. What error do you see in both cases?
  3. Write a function that makes a 32-byte base64url token using randomBytes. Compare its length to a token made from Math.random().toString(36).
  4. In a Next.js app, make a variable called NEXT_PUBLIC_TEST=hello and one called PRIVATE_TEST=hello. Build, then search the .next/static folder for hello. (I did not run this one; the Next docs say only the first should be inlined.)
  5. Extend the log redactor so it also redacts values that look like a JWT (three parts split by dots starting with eyJ), whatever the key name is.
  6. Put a self-signed certificate on a local HTTPS server and open it in the browser. What does the browser show? What is the safe way to handle that in development?
  7. Make a table of the personal fields in a project of yours. For each, write why you need it and how long you keep it.

Answers

  1. The .gitignore rule only affects untracked files. The earlier commit still holds the value, so git log -p -S finds it.
  2. Both fail with Unsupported state or unable to authenticate data (the message I saw), because the tag check fails. A bad key and a changed ciphertext look the same to the decryptor.
  3. randomBytes(32).toString("base64url") is 43 characters of high-quality randomness. The Math.random string is about 10 to 11 characters from a weak source.
  4. You should see hello for NEXT_PUBLIC_TEST only, in the built files. PRIVATE_TEST should be absent from the client files (per the Next.js docs; not run here).
  5. Add a value check, for example typeof v === "string" && /^eyJ[\w-]+\.[\w-]+\.[\w-]*$/.test(v), then return "[REDACTED]". This catches tokens in fields with surprising names.
  6. The browser warns that the certificate is not trusted. In development, trust that one certificate (or use a local development CA tool). Never turn off verification globally.
  7. There is no single right answer. Remove fields with no feature need, and give each remaining field a retention rule.

Interview questions

Hashing, encoding, encryption and signing: what is the difference? (Must know)

A hash is a one-way fingerprint with no key, used for comparing and detecting change, and with a slow salted function for passwords. Encoding, like base64, just changes the format and anyone can undo it. Encryption hides data and is reversible with a key. Signing, such as HMAC, uses a key to prove origin and no tampering, but the data remains readable. I pick by the job: store a password (slow hash), send bytes as text (encode), keep data secret (encrypt), prove a webhook is genuine (sign).

Why is base64 not security? (Trap)

It has no key. Anyone can decode it, and I showed cGF5IDEw... turning back into plain text with one function. Tokens and secrets sent as base64 are readable by everyone who sees them. A JWT payload is a good example: base64url, readable, protected only against changes by the signature.

How would you encrypt a field in a database? (Senior signal)

AES-256-GCM through node:crypto or a vetted library, with a random 12-byte IV for each encryption, and I store the IV and the auth tag next to the ciphertext. If the tag check fails on decryption, I treat it as corruption or tampering and raise an error. The key comes from a secret manager or KMS, not from the database or the repo, and I store a key version so I can rotate. Ideally a data key encrypts the field and a separate key protects the data key. And I ask first if I need to store the value at all.

What is wrong with Math.random() for a reset token? (Must know)

It is a pseudo-random generator for speed, not for secrets, and its output can be predicted if the state is learned. A reset token, session ID or key must come from a cryptographically secure generator, crypto.randomBytes in Node. OWASP lists Math.random() as the unsafe choice for Node and crypto.randomBytes() as the safe one.

A developer committed an API key by mistake and then deleted it in the next commit. Is it fixed? (Must know)

No. Git history still contains it, and anyone with a clone, a fork or a cached copy can read it. First I rotate the key and disable the old one, then check the provider logs for misuse. After that I can remove it from history with a tool like git filter-repo for tidiness, add a pre-commit scanner and push protection, and move the secret to a secret store. Rotation is the real fix.

Where is it safe to put a secret in a Next.js app? (Must know)

In server-only environment variables, read only in server code (route handlers, server components, server actions). Variables prefixed with NEXT_PUBLIC_ are inlined into the browser bundle, so they are public by design and I use that prefix only for values that are safe to publish. If the browser needs something that requires a secret, the browser calls my server and my server makes the call. I also keep .env*.local out of git.

What does HTTPS give you, and what does it not? (Must know)

It gives me a check of the server's identity through its certificate, encryption of data in transit, and detection of changes. It does not stop XSS, injection or broken access control, because those happen at the endpoints. HSTS adds that the browser will refuse plain HTTP for my site for a set time. I would also never disable certificate verification to make an error go away.

What is HSTS and what is the risk of setting it wrong? (Senior signal)

HSTS is a response header that tells the browser to always use HTTPS for the site, and to block clicking through certificate warnings. The risk is that a long max-age with includeSubDomains sticks in browsers. If some subdomain cannot serve HTTPS, it becomes unreachable until the time runs out. So I roll it out with a short max-age first, then raise it once everything is verified.

Your team found an AWS key in a public repo. Walk me through the response. (Senior signal)

I deactivate and rotate the key right away, because it has to be assumed copied. Then I review the cloud audit logs and billing for the period since the commit, to see what the key did, and check for new resources, users or data access. I remove the key from code and move the new one to a secret store with the minimum permissions. I clean the history and tell people to re-clone as a secondary step. I decide with the team whether it's a data incident that needs reporting. Finally I add scanning and push protection, plus a short-lived-credential approach, so this is harder to repeat.

How do you handle personal data in logs? (Senior signal)

I keep it out. I log IDs, not emails, names or addresses, and I never log passwords, tokens, session IDs or card numbers. I use the logger's redaction feature for known fields and avoid logging whole bodies. Logs have their own retention period, and access to them is limited, because they are copied widely. If I need to trace a person, I use a pseudonymous user ID.

Is a secret scanner enough? (Trap)

No. Scanners help but they miss things (a password with no recognisable pattern) and flag harmless strings. They are a safety net. The main protection is good structure: secrets never in code, a secret manager, scoped and short-lived credentials, .gitignore set before the first commit, and push protection so a leak stops at the push. And scanning only the working directory is not enough: scan history.

When would you encrypt and when would you hash user data? (Trap)

If I never need the original value back (a password, a one-time token), I hash it, and for passwords with a slow salted function. If I need to read it back (an address to ship to, a stored third-party token), I encrypt it, or I protect it with access control and do not store more than needed. Hashing something I must display, or encrypting something I only need to compare, are both signs of the wrong tool.

Key takeaways

  • A hash fingerprints, encoding reformats, a MAC signs, encryption hides. Base64 is never protection.
  • Use AES-256-GCM with a fresh random IV every time, and store the IV and tag. A changed byte makes decryption fail on purpose.
  • The key is the real secret. Keep it in a secret manager, away from the data, and plan to rotate it.
  • For anything unguessable, use crypto.randomBytes (or randomUUID/randomInt). Never Math.random.
  • Secrets leak through committed .env files, git history, NEXT_PUBLIC_ variables, logs, error messages and URLs.
  • If a secret leaks: rotate first, check for use, then clean up. Deleting a commit does not undo the exposure.
  • Scan history with tools like Gitleaks and use GitHub push protection, but do not depend on them alone.
  • HTTPS gives identity, privacy and integrity in transit, not safety of the app. Use HSTS carefully. Never turn off certificate checks.
  • Collect less personal data, keep it for less time, keep it out of logs, and say who can read it.

What's next

Lesson 6 looks at dependencies and the supply chain: how a package you did not write can hurt you, lockfiles, npm audit, Dependabot and keeping secrets safe in GitHub Actions. Practise now: run a history scan on one of your own repos (with Gitleaks or even git log -p -S for words like password, secret and AKIA), check what is behind NEXT_PUBLIC_ in your project, and look through your logs for emails and tokens.

Published by Jainik · Last updated · How these lessons are made

Keep learning

Related lessons

Part of the study guide

Related series