ZMedia Purwodadi

Authentication and Password Security - Building Safer Login Systems

Table of Contents
Authentication and password security


Login Is More Than Email and Password

Most beginner developers build a login system by checking whether the email and password match a record in the database. If the match is correct, the user enters the dashboard. At first, this feels complete.

But a real login system has more responsibility than that.

A login system protects user identity. It decides who can enter the application. It protects private data, account settings, payments, messages, dashboards, and admin areas. If authentication is weak, the whole application becomes weak.

A working login system and a secure login system are not the same.

A working login system allows the correct user to enter. A secure login system also protects passwords, prevents repeated guessing, manages sessions safely, avoids leaking account details, and handles password reset carefully.

Basic Authentication Flow

User enters email and password
        |
        v
Backend validates input
        |
        v
Find user by email
        |
        v
Compare entered password with stored password hash
        |
        v
Create session or token
        |
        v
Return safe response to user

This flow looks simple, but each step needs careful handling.

The backend should validate input before checking the database. The password should be compared with a hash, not plain text. The response should not expose unnecessary user details. The session or token should be protected after login.

Authentication is not just one function. It is a complete security flow.

Link to:Web application security

Passwords Should Never Be Stored Directly

A very common beginner mistake is storing passwords directly in the database.

Example of unsafe data:

email: user@example.com
password: mypassword123

This is dangerous. If the database leaks, every password becomes visible. Users often reuse passwords on multiple websites, so one leaked password can create risk outside your application also.

A safer application stores a password hash.

Example of safer data:

email: user@example.com
passwordHash: $2b$12$zN8...

The original password is not stored. During login, the entered password is hashed and compared with the stored hash.

This is one of the most important security habits in backend development.

Password Hashing

Password hashing converts a password into a one-way value. A good password hash cannot be reversed easily.

Hashing is different from encryption. Encryption is designed to be reversed with a key. Hashing is designed to be one-way.

For passwords, developers should use trusted password hashing algorithms such as bcrypt, Argon2, or PBKDF2.

Do not create your own password hashing logic. Security algorithms are difficult to design correctly, and custom password logic usually becomes weak.

Link to:JWT Security

Installing bcrypt

For a Node.js backend, bcrypt is commonly used.

npm install bcrypt

bcrypt adds a salt automatically and makes password guessing harder by using a cost factor.

Password Hashing Code Example

const bcrypt = require("bcrypt");
async function createUser(email, plainPassword) {  const saltRounds = 12;  const passwordHash = await bcrypt.hash(plainPassword, saltRounds);
  const user = await User.create({ email, passwordHash });
  return {     id: user.id,     email: user.email   };}

In this example, the plain password is used only during account creation. The database stores passwordHash, not the original password.

The function returns only safe user details. It does not return password hash to the frontend.

Password Verification Code Example

async function loginUser(email, plainPassword) {  const user = await User.findOne({ email });
  if (!user) {    return { success: false, message: "Invalid email or password." };  }
  const isPasswordValid = await bcrypt.compare(plainPassword, user.passwordHash);
  if (!isPasswordValid) {    return { success: false, message: "Invalid email or password." };  }
  return { success: true, user: { id: user.id, email: user.email } };}

Notice the error message. It does not say “email not found” or “wrong password.” It uses the same message for both cases.

This prevents attackers from easily checking which email addresses exist in the system.

Safe Login Response

A login response should return only what the frontend needs.

Good response:

{
  "message": "Login successful",
  "user": {
    "id": "123",
    "email": "user@example.com"
  }
}

Bad response:

{
  "message": "Login successful",
  "user": {
    "id": "123",
    "email": "user@example.com",
    "passwordHash": "$2b$12$zN8...",
    "roleSecret": "internal-value"
  }
}

The frontend does not need password hash or internal secrets. Sensitive fields should stay on the server.

A safe API response is part of authentication security.

Input Validation Before Login

Before checking credentials, validate the request body.

A login API should not process empty values, invalid types, very long strings, or malformed data.

function validateLoginInput(req, res, next) {  const { email, password } = req.body;
  if (!email || typeof email !== "string" || !email.includes("@")) {    return res.status(400).json({ message: "Invalid email or password." });  }
  if (!password || typeof password !== "string" || password.length > 200) {    return res.status(400).json({ message: "Invalid email or password." });  }
  next();}

Validation does not replace password checking. It protects the backend from processing unexpected input.

For production projects, validation libraries can make this cleaner.

Login Route Example

app.post("/api/login", validateLoginInput, async (req, res) => {
  const { email, password } = req.body;

  const result = await loginUser(email, password);

  if (!result.success) {
    return res.status(401).json({
      message: result.message
    });
  }

  res.json({
    message: "Login successful",
    user: result.user
  });
});

This route separates responsibilities clearly.

Validation checks input.
Login logic checks credentials.
The route returns a safe response.

Clean separation makes authentication code easier to maintain.

Brute Force Attacks

A brute force attack happens when someone repeatedly tries different passwords until one works.

If a login API allows unlimited attempts, attackers can keep guessing.

A good login system should limit repeated attempts.

Rate limiting is one of the simplest protections.

Protecting Against Brute Force Attacks

Limit repeated login attempts using rate limiting:

const rateLimit = require("express-rate-limit");
const loginLimiter = rateLimit({  windowMs: 15 * 60 * 1000, // 15 minutes  max: 5,  message: { message: "Too many login attempts. Please try again later." }});
app.post("/api/login", loginLimiter, validateLoginInput, loginController);

This prevents attackers from guessing passwords endlessly.

Installing Rate Limiting Package

npm install express-rate-limit

Login Rate Limiting Example

const rateLimit = require("express-rate-limit");

const loginLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,
  max: 5,
  message: {
    message: "Too many login attempts. Please try again later."
  }
});

app.post("/api/login", loginLimiter, validateLoginInput, loginController);

This limits repeated login attempts from the same source within a time window.

Rate limiting is not perfect, but it reduces basic abuse and protects server resources.

Account Lockout

Account lockout temporarily blocks login after repeated failed attempts.

This can protect accounts, but it must be designed carefully.

If lockout is too strict, attackers can intentionally lock other users out of their accounts. If it is too weak, it may not stop repeated guessing.

A balanced approach is better:

Allow limited failed attempts
Delay further attempts
Notify user when suspicious activity happens
Use CAPTCHA only when needed
Avoid permanent lock without recovery option

For many beginner projects, rate limiting is easier than building complex lockout logic.

Link to:JWT Security

Password Strength Rules

Password strength rules help users create safer passwords.

A password should not be too short or too easy to guess.

Basic password rule:

Minimum 8 characters
Avoid common passwords
Allow longer passwords
Do not force strange rules unnecessarily

Some old systems force users to use uppercase, lowercase, number, symbol, and frequent password changes. These rules can sometimes make users create predictable passwords.

A better habit is to allow long passwords and block very common weak passwords.

For beginner projects, at least enforce a reasonable minimum length and avoid storing passwords directly.

Password Reset Security

Password reset is one of the most sensitive parts of authentication.

If password reset is weak, attackers may take over accounts even without knowing the old password.

A safe password reset flow should use a temporary token.

User requests password reset
        |
        v
Server creates short-lived reset token
        |
        v
Reset link sent to registered email
        |
        v
User opens reset link
        |
        v
Server verifies token
        |
        v
User sets new password
        |
        v
Token becomes invalid

The reset token should expire quickly and should not be reusable.

Password Reset Token Example

const crypto = require("crypto");

function createResetToken() {
  const token = crypto.randomBytes(32).toString("hex");

  const expiresAt = new Date(Date.now() + 15 * 60 * 1000);

  return {
    token,
    expiresAt
  };
}

This creates a random token with a 15-minute expiry.

In production, it is better to store a hashed version of the reset token in the database instead of storing the plain token directly.

Password Reset Mistakes

Common password reset mistakes include:

Reset token never expires
Same reset token can be used many times
Reset link is sent to a new email without verification
Token is stored as plain text
Password reset reveals whether email exists
No rate limit on reset requests

A password reset feature should be treated as carefully as login.

A weak reset system can bypass strong password security.

Session-Based Authentication

Session-based authentication stores login state on the server.

After login, the server creates a session and sends a session ID to the browser, usually inside a cookie.

User logs in
   |
   v
Server creates session
   |
   v
Browser receives session cookie
   |
   v
Browser sends cookie on future requests
   |
   v
Server checks session

Sessions are common in traditional web applications.

They are easier to invalidate because the server controls the session record.

Secure Cookie Settings

If sessions use cookies, cookie settings matter.

res.cookie("sessionId", sessionId, {
  httpOnly: true,
  secure: true,
  sameSite: "lax",
  maxAge: 1000 * 60 * 60
});

Important settings:

HttpOnly  - JavaScript cannot read the cookie
Secure    - cookie is sent only over HTTPS
SameSite  - helps reduce CSRF risk
MaxAge    - limits cookie lifetime

A session cookie should not be available forever. Long-lived sessions increase risk when a device is lost or shared.

Token-Based Authentication

Token-based authentication is common in APIs, mobile apps, and single-page applications.

After login, the server returns a token. The client sends the token in future requests.

Login request
   |
   v
Server verifies credentials
   |
   v
Server creates token
   |
   v
Client sends token with API requests

Example request header:

Authorization: Bearer your_token_here

Tokens are useful, but they must be protected. A stolen token can allow account access until it expires or is revoked.

JWT Authentication

JWT means JSON Web Token. It is a common token format used in web applications.

A JWT usually contains signed information such as user ID, role, and expiry time.

A JWT should not contain private data like passwords, secret keys, or sensitive personal details.

Good JWT payload idea:

{
  "userId": "123",
  "role": "user",
  "exp": 1710000000
}

Bad JWT payload idea:

{
  "userId": "123",
  "password": "mypassword123",
  "creditCard": "1234..."
}

JWT data can often be decoded by the client, so never put sensitive secrets inside it.

JWT Creation Example

const jwt = require("jsonwebtoken");

function createAccessToken(user) {
  return jwt.sign(
    {
      userId: user.id,
      role: user.role
    },
    process.env.JWT_SECRET,
    {
      expiresIn: "15m"
    }
  );
}

Install JWT package:

npm install jsonwebtoken

The secret should come from environment variables, not hardcoded source code.

Protecting Routes with JWT

const jwt = require("jsonwebtoken");

function authenticate(req, res, next) {
  const authHeader = req.headers.authorization;

  if (!authHeader || !authHeader.startsWith("Bearer ")) {
    return res.status(401).json({
      message: "Authentication required."
    });
  }

  const token = authHeader.split(" ")[1];

  try {
    const payload = jwt.verify(token, process.env.JWT_SECRET);
    req.user = payload;
    next();
  } catch (error) {
    return res.status(401).json({
      message: "Invalid or expired token."
    });
  }
}

This middleware checks whether the request has a valid token before allowing access to protected routes.

Refresh Tokens

Access tokens should usually be short-lived. Refresh tokens are used to get new access tokens without forcing the user to log in again every few minutes.

Refresh tokens need stronger protection because they live longer.

Safe refresh token habits:

Store securely
Rotate when used
Allow logout to invalidate token
Keep expiry time
Do not expose in logs
Use HTTPS only

If refresh tokens are handled badly, attackers can keep access for a long time.

Logout

Logout should not only remove data from the frontend.

For session-based login, logout should destroy the server-side session.

For token-based systems, logout may involve removing refresh tokens, clearing cookies, or adding token invalidation logic.

Simple client-side logout is not always enough.

Bad logout idea:

Only remove token from local storage

Better logout idea:

Remove client token
Invalidate refresh token on server
Clear secure cookie
Redirect user safely

Logout is part of authentication security, not just a UI feature.

Multi-Factor Authentication

Multi-Factor Authentication, or MFA, adds another step after password login.

Examples:

OTP from authenticator app
Email verification code
SMS code
Hardware security key

MFA reduces risk when a password is stolen.

For beginner projects, MFA may be optional. For financial apps, admin dashboards, and sensitive systems, MFA is very useful.

SMS-based OTP is common, but authenticator apps or hardware keys are stronger in many professional systems.

Email Verification

Email verification confirms that the user owns the email address.

Without email verification, users may register with fake or wrong emails.

A basic email verification flow:

User signs up
   |
   v
Server creates verification token
   |
   v
Email sent to user
   |
   v
User clicks verification link
   |
   v
Account marked as verified

Email verification helps improve account quality and password recovery safety.

For important apps, users should verify email before accessing sensitive features.

Account Enumeration

Account enumeration happens when an application reveals whether an account exists.

Example risky message:

No account found with this email.

This may help attackers collect valid emails.

Safer message for login:

Invalid email or password.

Safer message for password reset:

If an account exists, a reset link will be sent.

This does not confirm whether the email exists.

Small wording choices can improve security.

Safe Authentication Architecture

Browser or Mobile App
        |
        | HTTPS
        v
Login API
        |
        | Validate input
        v
User lookup
        |
        | Compare password hash
        v
Session or token creation
        |
        | Secure cookie or access token
        v
Protected API routes
        |
        | Authentication middleware
        | Authorization middleware
        v
Application data

This architecture separates login, token/session handling, protected routes, and authorization checks.

Clean structure makes authentication easier to debug and safer to maintain.

Authentication Debugging Tips

Authentication bugs can be confusing because the problem may be in the frontend, backend, cookie, token, database, or environment variable.

Useful checks:

Check request body
Check backend validation
Check user exists in database
Check password hash comparison
Check JWT secret value
Check token expiry
Check cookie settings
Check HTTPS requirement
Check CORS settings
Check authorization middleware

Useful command for testing login API:

curl -X POST https://example.com/api/login \
  -H "Content-Type: application/json" \
  -d '{"email":"user@example.com","password":"password123"}'

Useful command for testing protected API:

curl -X GET https://example.com/api/profile \
  -H "Authorization: Bearer YOUR_ACCESS_TOKEN"

These commands help test the backend without depending only on the frontend UI.

CORS and Authentication

CORS controls which origins can call your backend from browsers.

If frontend and backend are on different domains, CORS must be configured correctly.

Example:

const cors = require("cors");

app.use(cors({
  origin: "https://yourfrontend.com",
  credentials: true
}));

For cookie-based authentication, credentials: true is important.

A common mistake is allowing all origins with credentials. That can create risk.

Avoid this in production:

app.use(cors({
  origin: "*",
  credentials: true
}));

Production CORS should allow only trusted frontend domains.

Secrets Used in Authentication

Authentication systems often use secrets.

Examples:

JWT secret
Session secret
Password reset token secret
Email service password
OAuth client secret

These values should not be stored inside source code.

Use environment variables:

const jwtSecret = process.env.JWT_SECRET;
const sessionSecret = process.env.SESSION_SECRET;

Example .env file:

JWT_SECRET=your_long_random_secret
SESSION_SECRET=your_session_secret

Add .env to .gitignore:

.env
node_modules/
dist/
build/

If a secret is exposed publicly, rotate it immediately.

Common Beginner Mistakes

Beginner authentication mistakes usually come from focusing only on “login works.”

Common mistakes:

Storing passwords as plain text
Returning password hash in API response
Using weak JWT secret
Keeping JWT valid for too long
Showing different login errors for email and password
No rate limit on login
No expiry for reset tokens
Only hiding buttons instead of checking backend permissions
Using localStorage carelessly for sensitive tokens
Uploading .env file to GitHub
Skipping HTTPS in production

These mistakes are avoidable. The best habit is to think about misuse while building the feature.

Interview-Relevant Authentication Points

Authentication is a common interview topic for backend, full stack, and web developer roles.

Strong interview answers should connect concepts to real application behavior.

Authentication verifies identity.
Authorization checks permissions.
Passwords should be hashed using bcrypt, Argon2, or PBKDF2.
JWT should not store sensitive information.
Access tokens should expire.
Refresh tokens need careful storage and rotation.
Cookies should use HttpOnly, Secure, and SameSite.
Rate limiting helps reduce brute force attempts.
Password reset tokens should expire and be single-use.
Backend must enforce access rules, not just frontend UI.

These points show practical understanding rather than memorized definitions.

Production Login Checklist

Before deploying authentication, check these items:

Passwords are hashed
Plain passwords are never stored
Login input is validated
Login attempts are rate limited
Error messages do not reveal account existence
Sessions or tokens expire
Cookies use secure settings
JWT secret is strong and private
Refresh tokens are protected
Password reset token expires
Logout invalidates session or refresh token
Protected routes use authentication middleware
Sensitive actions check authorization
HTTPS is enabled
Secrets are stored outside code
Logs do not contain passwords or tokens

This checklist will not make authentication perfect, but it prevents many serious beginner-level problems.

Authentication as User Trust

Authentication is not just a technical feature. It is a trust system.

When a user creates an account, they trust the application to protect their identity and data. A developer should treat that trust seriously.

A safe login system does not need to be complicated at the beginning. It needs correct basics: password hashing, safe error messages, protected sessions or tokens, rate limiting, secure reset flow, and backend permission checks.

These habits make an application more professional and safer for real users.

Link to:Web application security

Link to:JWT Security

Post a Comment