Most tutorials teach security like a shopping list.
"Add bcrypt. Add JWT. Add OAuth. Add HTTPS." And you do it, and it works, and you move on without ever knowing why one tool is used in one place and a different one somewhere else.
Open your app, log in, and copy the token from the Authorization header in DevTools. Paste it into jwt.io.
Everything inside is sitting there in plain text. User ID, role, email, maybe more. No password or key needed.
Most developers think: "That's fine, it's signed."
Signed means nobody can change it. It doesn't mean nobody can read it. Mixing those up is where a lot of security mistakes start.
The same goes for the rest of the toolbox: passwords get encrypted when they should be hashed, tokens end up in URLs, and a role field in a request body gets trusted. The tools aren't the problem. Knowing which one goes where is.
Before adding any security tool, ask:
What exactly am I protecting, and from whom?
Which Tool for Which Job
| I need to... | Use | You already see this in |
|---|---|---|
| Store a password | Hashing (bcrypt / Argon2) | Every login page |
| Store data I need to read back | AES-256-GCM + IV | Phone storage encryption |
| Two strangers agree on a secret | Asymmetric keys | WhatsApp, HTTPS |
| Keep a user logged in | JWT | React app calling a Node API |
| Let another app access my data | OAuth 2.0 | "Continue with Google" |
| Control who can do what | RBAC | Hospital app: doctor / receptionist |
Hashing: For Passwords Only
Hashing is one-way. There's no route back, and that's the point. If your DB leaks, the attacker gets hashes, not passwords.
import bcrypt from "bcrypt";
const passwordHash = await bcrypt.hash(password, 12); // signup
const isValid = await bcrypt.compare(password, user.passwordHash); // login
Don't encrypt passwords (reversible = risky), and don't use plain SHA-256 or MD5 (too fast, so easy to brute-force). This is also why "Forgot password" resets your password instead of emailing it.
Encryption + IV: For Data You Must Read Back
Need the original value later, like a phone number or a third-party API key? Use AES-256-GCM.
The IV must be random and new every time. It is not secret, so store it next to the data. Reusing one with the same key breaks GCM.
import crypto from "crypto";
const key = Buffer.from(process.env.DATA_KEY, "hex"); // 32 bytes, never in Git
export function encrypt(text) {
const iv = crypto.randomBytes(12);
const cipher = crypto.createCipheriv("aes-256-gcm", key, iv);
const data = Buffer.concat([cipher.update(text, "utf8"), cipher.final()]);
return [iv, cipher.getAuthTag(), data].map(b => b.toString("base64")).join(".");
}
You store iv.tag.ciphertext. The key lives in an env var or secrets manager, never next to the data.
Public/Private Keys: How WhatsApp Works Without Reading Your Messages
Asymmetric crypto uses a public key (share with anyone) and a private key (never leaves your device).
ENCRYPT: public key locks → private key unlocks
SIGN: private key signs → public key verifies
WhatsApp: when you message someone new, your phone fetches their public key and combines it with your private key to reach a shared secret. Then AES encrypts the actual message. WhatsApp's server only relays scrambled data, so it can't read it. (WhatsApp uses the Signal Protocol with elliptic-curve keys rather than RSA, but the idea is the same.)
HTTPS: your browser and the server agree on a temporary secret this way, then switch to fast AES. The server's certificate is signed, so you know it's really your bank.
Where you'll meet RSA: SSH keys, JWTs signed with RS256 (Auth0, Keycloak), and Digital Signature Certificates for GST filing.
Asymmetric crypto is slow, so it agrees on the secret and AES does the heavy lifting. Never use RSA to encrypt large data directly.
JWT: A Signed Postcard, Not a Locked Box
xxxxx.yyyyy.zzzzz
Header.Payload.Signature
{ "sub": "user_123", "role": "editor", "iss": "my-api", "exp": 1790000900 }
Put in the payload: user ID, role, expiry, issuer.
Never put in it: passwords, hashes, phone numbers, addresses, card data, secrets. If you wouldn't write it on a postcard, keep it out.
const accessToken = jwt.sign(
{ sub: user.id, role: user.role },
process.env.JWT_SECRET,
{ algorithm: "HS256", expiresIn: "15m" }
);
- Access tokens live about 15 minutes. Refresh tokens live longer and are rotated on use.
- On verify, pin the algorithm (
algorithms: ["HS256"]) to blockalg: nonetricks.
"Bearer" Means Whoever Holds It Gets In
Authorization: Bearer eyJhbGciOiJIUzI1NiIs...
It works like a cinema ticket. The door staff don't check your ID, they check the ticket. If someone steals it, they walk in as you. So:
- Always HTTPS
- Short expiry
- Never in the URL (logs, history and Referer headers keep URLs)
- Never
console.logyour headers in production
Header, Body, Cookie or URL?
The place you put a value decides who can see it.
| Data | Send it in | Never in |
|---|---|---|
| Password (login only) | POST body over HTTPS | URL, GET, logs |
| Access token |
Authorization: Bearer header |
URL, body |
| Refresh token | httpOnly + Secure cookie | localStorage |
| Role / user ID | Read from the verified token | A field the client sends |
| Password hash | Nowhere | Any API response |
| API secrets | Server env vars | Frontend, Git, JWT |
The password travels once, at login. After that only the token does.
app.post("/auth/login", async (req, res) => {
const { email, password } = req.body;
const user = await User.findOne({ email });
const valid = user && (await bcrypt.compare(password, user.passwordHash));
if (!valid) return res.status(401).json({ message: "Invalid credentials" }); // same message either way
res.cookie("refreshToken", signRefreshToken(user), {
httpOnly: true, secure: true, sameSite: "strict",
});
res.json({ accessToken: signAccessToken(user) }); // no password, no hash
});
Use the same error message for a wrong email and a wrong password. Otherwise you're telling attackers which emails exist.
RBAC: Check on the Server, Every Time
Users get roles, roles carry permissions. In a hospital app, the doctor reads records, the receptionist books appointments, and the admin manages staff.
const authorize = (...roles) => (req, res, next) =>
roles.includes(req.user.role)
? next()
: res.status(403).json({ message: "Forbidden" });
app.delete("/users/:id", authenticate, authorize("admin"), deleteUser);
- Deny by default.
- Hiding the Delete button in React is not security. Anyone can use Postman.
- Add ownership checks too. An editor edits their own posts, not everyone's.
-
Never trust a
rolein the request body. Anyone can send"role": "admin".
OAuth 2.0: What "Continue with Google" Really Does
YOUR APP GOOGLE USER
│ 1. click button │ │
│ ── redirect ──────────▶ │ 2. Google login ◀────│
│ │ 3. "Allow access?" ◀─│
│ 4. back with ?code= ◀── │ │
│ 5. backend swaps code ─▶│ │
│ 6. tokens returned ◀─── │ │
Your app never sees the user's Google password. It only gets a token saying "Google confirms this is suresh@gmail.com." After that, Google is out of the picture. You create your own JWT and RBAC works as usual, because Google has no idea who's an admin in your app.
OAuth is used for two things:
- Login: "Sign in with Google" (technically OpenID Connect, built on OAuth 2.0)
- Data access: Calendly reading your Google Calendar, Vercel importing your GitHub repos, with limited access the user can revoke
Use Authorization Code + PKCE, verify the ID token instead of just decoding it, and send a state value to block forged callbacks.
Skip OAuth if it's your own app with your own users. Email + hashed password + JWT is simpler and fine.
The Hotel Analogy
- Hashing: the front desk keeps a scrambled fingerprint of your ID, so they can check it but not rebuild it
- Encryption: the room safe, opened only with the key
- JWT: your key card. Its printed number is readable, so don't print your passport on it
- RBAC: the card's level (guest, staff, manager) decides which doors open
- OAuth: "let the taxi driver pick up my bag from room 204, nothing else"
Checklist
- Passwords → bcrypt / Argon2, never encrypted
- Data I need back → AES-256-GCM, fresh IV every time
- Secrets → env vars, never Git
- JWT → short expiry, pinned algorithm, nothing sensitive inside
- Access token →
Authorization: Bearerheader. Refresh token → httpOnly cookie - Role and user ID → from the verified token, never the body
- HTTPS everywhere
- Authorization checked on the server for every protected route
Final Thought
No single tool is "the secure one." Hashing doesn't protect data in transit, a JWT hides nothing, and RBAC doesn't help if your token leaks. Each answers one narrow question.
Once you can explain why the password is hashed, the phone number is encrypted and the role is checked on the server, you already understand more than a lot of production codebases.
Top comments (0)