DEV Community

Cover image for Signed Doesn't Mean Secret: Where Every Security Tool Actually Belongs
Suresh
Suresh

Posted on

Signed Doesn't Mean Secret: Where Every Security Tool Actually Belongs

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
Enter fullscreen mode Exit fullscreen mode

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(".");
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
{ "sub": "user_123", "role": "editor", "iss": "my-api", "exp": 1790000900 }
Enter fullscreen mode Exit fullscreen mode

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" }
);
Enter fullscreen mode Exit fullscreen mode
  • Access tokens live about 15 minutes. Refresh tokens live longer and are rotated on use.
  • On verify, pin the algorithm (algorithms: ["HS256"]) to block alg: none tricks.

"Bearer" Means Whoever Holds It Gets In

Authorization: Bearer eyJhbGciOiJIUzI1NiIs...
Enter fullscreen mode Exit fullscreen mode

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.log your 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
});
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode
  • 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 role in 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 ◀─── │                      │
Enter fullscreen mode Exit fullscreen mode

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: Bearer header. 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)