Warming up the neural circuits...
By the end of this chapter you will:
Authentication without authorization means any logged-in user can do anything. Authorization without authentication is meaningless because you don't know who's asking.
People mix these up constantly. Learn the difference now.
Login:
POST /auth/login { username, password }access_token (15 min) and a refresh_token (7 days){ access_token: "eyJ...", refresh_token: "..." }Every subsequent request:
4. Agent sends: GET /orders with Authorization: Bearer eyJ...
5. JwtGuard reads the Bearer token, verifies the signature, checks expiry, puts { sub: "user-id", role: "maker" } into req.user
When the access token expires:
7. Agent sends: POST /auth/refresh { refresh_token: "..." }
8. Server validates the refresh token (checks it exists in Redis, not revoked), issues a new access_token, rotates the refresh_token (old one is invalidated)
JWT_ACCESS_SECRET=a_long_random_string_minimum_32_chars
JWT_REFRESH_SECRET=a_completely_different_long_stringIf you use one secret for both, a stolen refresh token could be used to forge an access token.
cannot be revoked once issued (unless you build a blocklist). If an access token is stolen, the attacker has it until it expires. 15 minutes limits the damage window.
Store a hash of the refresh token in the database. On every refresh:
Logout = delete the DB row. The refresh token is now invalid everywhere.
The JWT payload is base64-encoded, not encrypted. Anyone who has the token can decode and read it. Safe to put: user_id, role. NOT safe to put: phone numbers, email, account balance.
A JWT says "this person was valid 10 minutes ago." Before approving a payment, check the DB:
const user = await this.userModel.findByPk(jwtPayload.sub);
if (!user || !user.is_active || user.is_blocked) {
throw new UnauthorizedException('
export enum Role {
Admin = 'admin',
Maker = 'maker',
Checker = 'checker',
SuperChecker = 'super-checker',
}
// On a controller method
@UseGuards(JwtGuard
For finer-grained rules (e.g. "only the maker who created this order, or any admin"), do the check inside the service:
async approve(user: AuthUser, orderId: string) {
const order = await this.orderModel.findByPk(orderId);
// Only the assigned checker or any admin can approve
if (order.checker_id
// ❌ Extremely dangerous
await User.create({ password: dto.password });
// ✅ Always hash with bcrypt before storing
const hash = await bcrypt.hash(dto.password, 12);
The number 12 is the cost factor — it makes bcrypt intentionally slow. Brute-forcing would take years.
const user = await this.userModel.findOne({ where: { username: dto.username } });
if (!user) throw new UnauthorizedException('Invalid credentials');
const
Return the same error message whether the username doesn't exist OR the password is wrong. Don't help attackers by confirming which one failed — "User not found" is a hint to enumerate usernames.
// ❌ Math.random() is NOT cryptographically secure
const resetToken = Math.random().toString(36);
// ✅ Use Node's crypto module
import { randomBytes } from 'crypto';
const resetToken = randomBytes(32).toString(CORS controls which websites can call your from a browser.
// ❌ This defeats CORS entirely — any website can call your API
app.enableCors({ origin: true, credentials: true });
// ✅ Explicit allowlist
app.enableCors({
origin: [
'https://app.quickbite.com',
'https://admin.quickbite.com',
'
CORS only protects browser-based requests. It does nothing against curl, Postman, or server-to-server calls. It's a browser security feature, not a firewall.
Add this in main.ts once and forget about it:
import helmet from 'helmet';
app.use(helmet());Helmet automatically sets headers that protect against common attacks:
| Header set by Helmet | Protects against |
|---|---|
X-Frame-Options: DENY | Clickjacking |
X-Content-Type-Options: nosniff | MIME sniffing attacks |
Strict-Transport-Security | Forces HTTPS |
Referrer-Policy | Prevents leaking URLs to other sites |
// ❌ Vulnerable — if email = "x' OR '1'='1"
const result = await sequelize.query(
`SELECT * FROM users WHERE email = '${email}'`
);
// Executes: SELECT * FROM users WHERE email = 'x' OR '1'='1'
// Returns ALL users!
// ✅ Safe — parameterized query
const result = await sequelize
Sequelize's model methods (.findAll, .findOne, .create) are parameterized by default — they're safe. The only dangerous place is raw SQL via sequelize.query(). When you must use raw SQL, always use bind parameters.
// ❌ Logs the entire config including JWT_SECRET and DB_PASSWORD
this.logger.log(`Starting with config: ${JSON.stringify(config)}`);
// ✅ Log only what's safe
this.logger.log('Application starting', { node_env
Before you mark an endpoint as done:
@UseGuards(JwtGuard) applied?Authentication proves identity. Authorization proves permission. A valid JWT means "this person logged in" — it does NOT mean "this person is allowed to access this specific resource." Always check resource ownership in the service layer.