July 12, 2026
Common Authentication Attacks and How I Improved Security in My Vue and Express Project
I am developing a clinic management application using Vue 3, Express, TypeScript, Sequelize, MySQL, and JWT.

By Thilina Malshan Fernando
15 min read
The application handles sensitive information, including:
- Patient records
- User accounts
- Appointments
- Payments
- Dashboard statistics
- SMS operations
Because the application handles private data, authentication security is very important.
Previously, my frontend stored the JWT inside sessionStorage. I changed the authentication system to use an HttpOnly cookie instead.
This article explains the main attacks that can happen in this type of application and the steps I used to reduce the risks.
1. Token Theft Through XSS
What Is the Attack?
XSS means Cross-Site Scripting.
It happens when an attacker manages to run malicious JavaScript inside the application.
For example, imagine that the JWT is stored like this:
sessionStorage.setItem("token", data.token);sessionStorage.setItem("token", data.token);A malicious script could read it:
const stolenToken = sessionStorage.getItem("token");const stolenToken = sessionStorage.getItem("token");The attacker could then send the token to another server and use it to access the victim's account.
The same problem exists when a token is stored in localStorage.
const stolenToken = localStorage.getItem("token");const stolenToken = localStorage.getItem("token");Both sessionStorage and localStorage can be accessed by JavaScript.
My Solution
I moved the JWT into an HttpOnly cookie.
res.cookie("token", result.token, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
maxAge: 60 * 60 * 1000,
path: "/",
});res.cookie("token", result.token, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
maxAge: 60 * 60 * 1000,
path: "/",
});The important setting is:
httpOnly: truehttpOnly: trueThis prevents frontend JavaScript from reading the cookie.
For example, this will not return the JWT:
console.log(document.cookie);console.log(document.cookie);The browser still sends the cookie automatically when the frontend makes an API request.
Important Limitation
HttpOnly cookies make token theft more difficult, but they do not completely stop XSS.
A malicious script may not be able to read the token, but it may still send requests from the user's browser.
For example:
fetch("https://api.example.com/patients", {
credentials: "include",
});fetch("https://api.example.com/patients", {
credentials: "include",
});Because of this, XSS prevention is still required.
Additional XSS Protection
Avoid using v-html with user-provided content.
Unsafe example:
<div v-html="patient.notes"></div><div v-html="patient.notes"></div>Safer example:
<div>{{ patient.notes }}</div><div>{{ patient.notes }}</div>Vue escapes normal template values automatically.
If HTML must be displayed, sanitize it first with a trusted HTML sanitization library.
Also:
- Validate user input.
- Escape output.
- Keep frontend packages updated.
- Avoid unknown scripts.
- Do not load JavaScript from untrusted websites.
- Add a Content Security Policy to the server that hosts the Vue application.
My Express backend only returns JSON, so its Content Security Policy does not protect the separately hosted Vue application. The CSP should be configured on the server that serves the frontend.
2. Cross-Site Request Forgery
What Is the Attack?
Cross-Site Request Forgery is normally called CSRF.
Cookies are automatically sent by the browser. This creates a possible risk.
Imagine that a user is logged into the clinic application. The user then visits a malicious website.
The malicious website may try to send a request like this:
<form action="https://api.example.com/users/delete" method="POST">
<input type="hidden" name="id" value="10" />
</form><form action="https://api.example.com/users/delete" method="POST">
<input type="hidden" name="id" value="10" />
</form>If the authentication cookie is sent with the request, the backend may think the request came from the real application.
My First Solution: SameSite Cookies
I use:
sameSite: "lax"sameSite: "lax"Full example:
res.cookie("token", token, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
maxAge: 60 * 60 * 1000,
path: "/",
});res.cookie("token", token, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
maxAge: 60 * 60 * 1000,
path: "/",
});SameSite=Lax stops the browser from sending the cookie with many cross-site requests.
This provides useful CSRF protection when the frontend and backend use the same site.
For example:
Frontend: https://app.example.com
Backend: https://api.example.comFrontend: https://app.example.com
Backend: https://api.example.comThese are different origins, but they are normally considered part of the same site because they share example.com.
Check the Request Origin
For sensitive requests, the backend can also verify the Origin header.
import { NextFunction, Request, Response } from "express";
const allowedOrigins = (process.env.CORS_ORIGINS || "")
.split(",")
.map((origin) => origin.trim())
.filter(Boolean);
const protectedMethods = new Set([
"POST",
"PUT",
"PATCH",
"DELETE",
]);
export const verifyRequestOrigin = (
req: Request,
res: Response,
next: NextFunction
) => {
if (!protectedMethods.has(req.method)) {
return next();
}
const origin = req.get("origin");
if (!origin || !allowedOrigins.includes(origin)) {
return res.status(403).json({
message: "Request origin is not allowed",
});
}
return next();
};import { NextFunction, Request, Response } from "express";
const allowedOrigins = (process.env.CORS_ORIGINS || "")
.split(",")
.map((origin) => origin.trim())
.filter(Boolean);
const protectedMethods = new Set([
"POST",
"PUT",
"PATCH",
"DELETE",
]);
export const verifyRequestOrigin = (
req: Request,
res: Response,
next: NextFunction
) => {
if (!protectedMethods.has(req.method)) {
return next();
}
const origin = req.get("origin");
if (!origin || !allowedOrigins.includes(origin)) {
return res.status(403).json({
message: "Request origin is not allowed",
});
}
return next();
};Register it before the routes:
app.use(verifyRequestOrigin);
app.use("/auth", authenticationRoutes);
app.use("/dashboard", dashboardRoutes);app.use(verifyRequestOrigin);
app.use("/auth", authenticationRoutes);
app.use("/dashboard", dashboardRoutes);When a CSRF Token Is Needed
A CSRF token should be added when:
- The frontend and backend are truly on different sites.
- The cookie uses
SameSite=None. - The application accepts sensitive browser requests from several websites.
- Stronger CSRF protection is required.
When using:
sameSite: "none"sameSite: "none"you must also use:
secure: truesecure: trueExample:
res.cookie("token", token, {
httpOnly: true,
secure: true,
sameSite: "none",
path: "/",
});res.cookie("token", token, {
httpOnly: true,
secure: true,
sameSite: "none",
path: "/",
});Do not use SameSite=None unless it is really needed.
3. Incorrect CORS Configuration
What Is the Attack?
CORS controls which browser origins can call the backend.
A dangerous or incorrect configuration is:
app.use(
cors({
origin: "*",
credentials: true,
})
);app.use(
cors({
origin: "*",
credentials: true,
})
);Cookies cannot safely be used with a wildcard origin.
The backend should not accept credentialed requests from every website.
My Solution
I keep the trusted frontend origins in an environment variable.
CORS_ORIGINS=http://localhost:5173,https://app.example.comCORS_ORIGINS=http://localhost:5173,https://app.example.comThen I create an allowlist:
import cors, { CorsOptions } from "cors";
const allowedOrigins = (process.env.CORS_ORIGINS || "")
.split(",")
.map((origin) => origin.trim())
.filter(Boolean);
const corsOptions: CorsOptions = {
origin: (origin, callback) => {
if (!origin) {
return callback(null, true);
}
if (allowedOrigins.includes(origin)) {
return callback(null, true);
}
console.error("CORS blocked origin:", origin);
return callback(
new Error(`CORS blocked for origin: ${origin}`)
);
},
credentials: true,
methods: [
"GET",
"POST",
"PUT",
"PATCH",
"DELETE",
],
allowedHeaders: [
"Content-Type",
"X-CSRF-Token",
],
};
app.use(cors(corsOptions));import cors, { CorsOptions } from "cors";
const allowedOrigins = (process.env.CORS_ORIGINS || "")
.split(",")
.map((origin) => origin.trim())
.filter(Boolean);
const corsOptions: CorsOptions = {
origin: (origin, callback) => {
if (!origin) {
return callback(null, true);
}
if (allowedOrigins.includes(origin)) {
return callback(null, true);
}
console.error("CORS blocked origin:", origin);
return callback(
new Error(`CORS blocked for origin: ${origin}`)
);
},
credentials: true,
methods: [
"GET",
"POST",
"PUT",
"PATCH",
"DELETE",
],
allowedHeaders: [
"Content-Type",
"X-CSRF-Token",
],
};
app.use(cors(corsOptions));The frontend must also enable credentials:
import axios from "axios";
const apiClient = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL,
withCredentials: true,
headers: {
"Content-Type": "application/json",
},
});
export default apiClient;import axios from "axios";
const apiClient = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL,
withCredentials: true,
headers: {
"Content-Type": "application/json",
},
});
export default apiClient;Important Point
CORS is not authentication.
CORS mainly controls browser requests. An attacker can still call the API using tools such as scripts, API clients, or command-line programs.
Every protected API must still use authentication and authorization middleware.
4. Brute-Force and Credential-Stuffing Attacks
What Is the Attack?
In a brute-force attack, an attacker tries many passwords until one works.
In a credential-stuffing attack, the attacker uses usernames and passwords leaked from other websites.
A login endpoint without limits may receive thousands of requests.
POST /auth/login
POST /auth/login
POST /auth/login
POST /auth/loginPOST /auth/login
POST /auth/login
POST /auth/login
POST /auth/loginMy Solution: Login Rate Limiting
Install the rate-limit package:
npm install express-rate-limitnpm install express-rate-limitCreate a limiter:
import rateLimit from "express-rate-limit";
export const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 5,
standardHeaders: true,
legacyHeaders: false,
message: {
message:
"Too many login attempts. Please try again later.",
},
});import rateLimit from "express-rate-limit";
export const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 5,
standardHeaders: true,
legacyHeaders: false,
message: {
message:
"Too many login attempts. Please try again later.",
},
});Apply it only to the login route:
router.post(
"/login",
loginLimiter,
login
);router.post(
"/login",
loginLimiter,
login
);This example allows five login attempts within 15 minutes from one IP address.
The exact number can be changed based on the application.
Use a General Error Message
Do not tell an attacker whether the username or password was incorrect.
Avoid:
{
"message": "Username exists, but password is incorrect"
}{
"message": "Username exists, but password is incorrect"
}Use:
{
"message": "Invalid username or password"
}{
"message": "Invalid username or password"
}Example:
if (!user) {
return res.status(401).json({
message: "Invalid username or password",
});
}
const passwordMatches = await bcrypt.compare(
password,
user.password
);
if (!passwordMatches) {
return res.status(401).json({
message: "Invalid username or password",
});
}if (!user) {
return res.status(401).json({
message: "Invalid username or password",
});
}
const passwordMatches = await bcrypt.compare(
password,
user.password
);
if (!passwordMatches) {
return res.status(401).json({
message: "Invalid username or password",
});
}This makes username discovery more difficult.
5. Weak or Leaked JWT Secrets
What Is the Attack?
The JWT secret is used to sign and verify tokens.
If the secret is weak, an attacker may guess it.
Dangerous examples include:
JWT_SECRET=secret
JWT_SECRET=123456
JWT_SECRET=myclinicJWT_SECRET=secret
JWT_SECRET=123456
JWT_SECRET=myclinicIf the secret is leaked, an attacker may create fake JWTs with an admin role.
My Solution: Generate a Strong Secret
A strong secret can be generated with Node.js:
node -e "console.log(require('crypto').randomBytes(64).toString('hex'))"node -e "console.log(require('crypto').randomBytes(64).toString('hex'))"Example environment configuration:
JWT_SECRET_CURRENT=a-long-random-secret-generated-securely
JWT_SECRET_PREVIOUS=
JWT_EXPIRES_IN=1hJWT_SECRET_CURRENT=a-long-random-secret-generated-securely
JWT_SECRET_PREVIOUS=
JWT_EXPIRES_IN=1hNever commit the real secret to Git.
Add environment files to .gitignore:
.env
.env.local
.env.production.env
.env.local
.env.productionProvide only an example file:
# .env.example
JWT_SECRET_CURRENT=
JWT_SECRET_PREVIOUS=
JWT_EXPIRES_IN=1h# .env.example
JWT_SECRET_CURRENT=
JWT_SECRET_PREVIOUS=
JWT_EXPIRES_IN=1hAdd JWT Validation Options
When creating the token:
import jwt from "jsonwebtoken";
const token = jwt.sign(
{
id: user.id,
username: user.user_name,
role: user.role,
authType: "db_user",
},
process.env.JWT_SECRET_CURRENT as string,
{
expiresIn: "1h",
algorithm: "HS256",
issuer: "clinic-api",
audience: "clinic-web",
}
);import jwt from "jsonwebtoken";
const token = jwt.sign(
{
id: user.id,
username: user.user_name,
role: user.role,
authType: "db_user",
},
process.env.JWT_SECRET_CURRENT as string,
{
expiresIn: "1h",
algorithm: "HS256",
issuer: "clinic-api",
audience: "clinic-web",
}
);When verifying it:
const decoded = jwt.verify(
token,
process.env.JWT_SECRET_CURRENT as string,
{
algorithms: ["HS256"],
issuer: "clinic-api",
audience: "clinic-web",
}
);const decoded = jwt.verify(
token,
process.env.JWT_SECRET_CURRENT as string,
{
algorithms: ["HS256"],
issuer: "clinic-api",
audience: "clinic-web",
}
);By checking the algorithm, issuer, and audience, the backend accepts only tokens created for this application.
Secret Rotation
New tokens can be signed with the current secret.
Old tokens can temporarily be checked with the previous secret.
import jwt from "jsonwebtoken";
export const verifyAuthToken = (token: string) => {
const currentSecret =
process.env.JWT_SECRET_CURRENT;
const previousSecret =
process.env.JWT_SECRET_PREVIOUS;
if (!currentSecret) {
throw new Error(
"JWT_SECRET_CURRENT is not configured"
);
}
try {
return jwt.verify(token, currentSecret, {
algorithms: ["HS256"],
issuer: "clinic-api",
audience: "clinic-web",
});
} catch (currentError) {
if (!previousSecret) {
throw currentError;
}
return jwt.verify(token, previousSecret, {
algorithms: ["HS256"],
issuer: "clinic-api",
audience: "clinic-web",
});
}
};import jwt from "jsonwebtoken";
export const verifyAuthToken = (token: string) => {
const currentSecret =
process.env.JWT_SECRET_CURRENT;
const previousSecret =
process.env.JWT_SECRET_PREVIOUS;
if (!currentSecret) {
throw new Error(
"JWT_SECRET_CURRENT is not configured"
);
}
try {
return jwt.verify(token, currentSecret, {
algorithms: ["HS256"],
issuer: "clinic-api",
audience: "clinic-web",
});
} catch (currentError) {
if (!previousSecret) {
throw currentError;
}
return jwt.verify(token, previousSecret, {
algorithms: ["HS256"],
issuer: "clinic-api",
audience: "clinic-web",
});
}
};A simple rotation process is:
- Move the current secret into
JWT_SECRET_PREVIOUS. - Create a new
JWT_SECRET_CURRENT. - Sign all new tokens with the current secret.
- Keep the previous secret until existing tokens expire.
- Remove the previous secret.
6. Role Manipulation
What Is the Attack?
The frontend stores safe user details in Pinia:
{
id: 4,
username: "john",
role: "user"
}{
id: 4,
username: "john",
role: "user"
}A user can open the browser console and change the frontend state.
For example:
authStore.user.role = "admin";authStore.user.role = "admin";This may make an admin button visible.
However, it must not give the user real admin access.
Frontend data cannot be trusted.
My Solution
The backend reads the user identity from the verified JWT and then checks the latest user information in the database.
const token = req.cookies?.token;
if (!token) {
return res.status(401).json({
message: "Unauthorized",
});
}
const decoded = verifyAuthToken(token) as AuthUser;
const user = await User.findByPk(decoded.id);
if (!user) {
return res.status(401).json({
message: "User not found",
});
}
if (!user.is_active) {
return res.status(403).json({
message: "Account is inactive",
});
}
req.user = {
id: user.id as number,
username: user.user_name,
role: user.role,
authType: "db_user",
};
return next();const token = req.cookies?.token;
if (!token) {
return res.status(401).json({
message: "Unauthorized",
});
}
const decoded = verifyAuthToken(token) as AuthUser;
const user = await User.findByPk(decoded.id);
if (!user) {
return res.status(401).json({
message: "User not found",
});
}
if (!user.is_active) {
return res.status(403).json({
message: "Account is inactive",
});
}
req.user = {
id: user.id as number,
username: user.user_name,
role: user.role,
authType: "db_user",
};
return next();The role comes from the database:
role: user.rolerole: user.roleIt does not come from the request body, Pinia, URL, or frontend form.
Protect Routes on the Backend
router.get(
"/stats",
authMiddleware,
authorizeRoles("admin"),
getStats
);router.get(
"/stats",
authMiddleware,
authorizeRoles("admin"),
getStats
);The middleware order is important.
Correct:
authMiddleware,
authorizeRoles("admin")authMiddleware,
authorizeRoles("admin")Incorrect:
authorizeRoles("admin"),
authMiddlewareauthorizeRoles("admin"),
authMiddlewareThe authentication middleware must run first because it creates req.user.
7. Insecure Direct Object Reference
What Is the Attack?
Insecure Direct Object Reference is often called IDOR.
It happens when a user changes an ID in a URL to access another user's data.
For example:
GET /patients/100GET /patients/100The user may change it to:
GET /patients/101GET /patients/101If the backend only checks that the user is logged in, the user may see another patient's record.
This is especially dangerous in a clinic application.
The Wrong Check
This is not enough:
router.get(
"/patients/:id",
authMiddleware,
getPatient
);router.get(
"/patients/:id",
authMiddleware,
getPatient
);It checks authentication, but it does not check whether the user can access that specific patient.
The Solution
The controller must check ownership or permission.
export const getPatient = async (
req: AuthRequest,
res: Response
) => {
const patientId = Number(req.params.id);
if (!Number.isInteger(patientId)) {
return res.status(400).json({
message: "Invalid patient ID",
});
}
const patient = await Patient.findByPk(patientId);
if (!patient) {
return res.status(404).json({
message: "Patient not found",
});
}
const isAdmin = req.user?.role === "admin";
const ownsRecord =
patient.assigned_user_id === req.user?.id;
if (!isAdmin && !ownsRecord) {
return res.status(403).json({
message:
"You do not have permission to view this patient",
});
}
return res.status(200).json({
patient,
});
};export const getPatient = async (
req: AuthRequest,
res: Response
) => {
const patientId = Number(req.params.id);
if (!Number.isInteger(patientId)) {
return res.status(400).json({
message: "Invalid patient ID",
});
}
const patient = await Patient.findByPk(patientId);
if (!patient) {
return res.status(404).json({
message: "Patient not found",
});
}
const isAdmin = req.user?.role === "admin";
const ownsRecord =
patient.assigned_user_id === req.user?.id;
if (!isAdmin && !ownsRecord) {
return res.status(403).json({
message:
"You do not have permission to view this patient",
});
}
return res.status(200).json({
patient,
});
};The exact ownership rule depends on the project.
For example, a record may belong to:
- A specific user
- A doctor
- A clinic branch
- A department
- An assigned employee
The backend must check this relationship before returning the data.
A role check answers:
Is this person an admin or user?Is this person an admin or user?An ownership check answers:
Can this person access this specific record?Can this person access this specific record?Both checks may be needed.
8. Disabled Users Continuing to Access the Application
What Is the Attack?
A user may receive a valid JWT and later be disabled by an administrator.
If the backend only trusts the token, the user may continue using the application until the token expires.
My Solution
On every authenticated request, the backend checks the database.
const user = await User.findByPk(tokenPayload.id);
if (!user) {
return res.status(401).json({
message: "User not found",
});
}
if (!user.is_active) {
return res.status(403).json({
message: "Account is inactive",
});
}const user = await User.findByPk(tokenPayload.id);
if (!user) {
return res.status(401).json({
message: "User not found",
});
}
if (!user.is_active) {
return res.status(403).json({
message: "Account is inactive",
});
}The backend also reads the latest role:
req.user = {
id: user.id as number,
username: user.user_name,
role: user.role,
authType: "db_user",
};req.user = {
id: user.id as number,
username: user.user_name,
role: user.role,
authType: "db_user",
};This means:
- Disabled users are blocked.
- Deleted users are blocked.
- Role changes take effect without waiting for a new token.
This adds a database query to authenticated requests, but it provides better control for an application with sensitive data.
9. Stolen JWT Replay
What Is the Attack?
HttpOnly protects the JWT from normal frontend JavaScript, but a token may still be stolen through:
- Malware
- Browser compromise
- Server logs
- An insecure network
- A leaked database or backup
- A compromised reverse proxy
An attacker may reuse the stolen token until it expires.
This is called a replay attack.
My Solution
First, I use HTTPS in production:
secure: process.env.NODE_ENV === "production"secure: process.env.NODE_ENV === "production"Production cookies must not travel over an unencrypted HTTP connection.
Second, I give the JWT a limited lifetime:
{
expiresIn: "1h"
}{
expiresIn: "1h"
}A shorter lifetime reduces the time a stolen token can be used.
Third, I do not log the cookie or token.
Avoid code such as:
console.log(req.cookies.token);console.log(req.cookies.token);Also make sure reverse proxies and monitoring tools do not store authentication cookies in logs.
Immediate Token Revocation
Normal JWT logout only removes the cookie from the current browser.
res.clearCookie("token", cookieOptions);res.clearCookie("token", cookieOptions);If an attacker already copied the JWT, clearing the browser cookie does not destroy the copied token.
For stronger revocation, a token_version field can be added to the user table.
Example model field:
token_version: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 0,
}token_version: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 0,
}Add it to the JWT:
const token = jwt.sign(
{
id: user.id,
username: user.user_name,
role: user.role,
tokenVersion: user.token_version,
},
process.env.JWT_SECRET_CURRENT as string,
{
expiresIn: "1h",
}
);const token = jwt.sign(
{
id: user.id,
username: user.user_name,
role: user.role,
tokenVersion: user.token_version,
},
process.env.JWT_SECRET_CURRENT as string,
{
expiresIn: "1h",
}
);Check it in the middleware:
if (
decoded.tokenVersion !== user.token_version
) {
return res.status(401).json({
message: "Session is no longer valid",
});
}if (
decoded.tokenVersion !== user.token_version
) {
return res.status(401).json({
message: "Session is no longer valid",
});
}To log the user out from every device:
await user.update({
token_version: user.token_version + 1,
});await user.update({
token_version: user.token_version + 1,
});All existing tokens for that user will become invalid.
10. Unsafe Cookie Configuration
What Is the Attack?
A cookie can become less secure when its options are incorrect.
For example:
res.cookie("token", token);res.cookie("token", token);This does not clearly configure HttpOnly, Secure, SameSite, expiration, or path.
My Solution
I keep the cookie options in one place.
import { CookieOptions } from "express";
const isProduction =
process.env.NODE_ENV === "production";
export const authCookieOptions: CookieOptions = {
httpOnly: true,
secure: isProduction,
sameSite: "lax",
maxAge: 60 * 60 * 1000,
path: "/",
};import { CookieOptions } from "express";
const isProduction =
process.env.NODE_ENV === "production";
export const authCookieOptions: CookieOptions = {
httpOnly: true,
secure: isProduction,
sameSite: "lax",
maxAge: 60 * 60 * 1000,
path: "/",
};Login uses the same options:
res.cookie(
"token",
result.token,
authCookieOptions
);res.cookie(
"token",
result.token,
authCookieOptions
);Logout should use matching options:
const {
maxAge,
...clearCookieOptions
} = authCookieOptions;
res.clearCookie(
"token",
clearCookieOptions
);const {
maxAge,
...clearCookieOptions
} = authCookieOptions;
res.clearCookie(
"token",
clearCookieOptions
);Then return the response:
res.status(200).json({
message: "Logged out successfully",
});res.status(200).json({
message: "Logged out successfully",
});Using shared options reduces the chance of setting and clearing the cookie differently.
Do Not Set an Unnecessary Domain
Avoid setting this unless it is required:
domain: ".example.com"domain: ".example.com"A wide cookie domain may allow more subdomains to receive the cookie.
It is safer to leave domain undefined when possible.
11. Dangerous Environment Admin Access
What Is the Attack?
My project has an optional environment admin account.
if (
username === process.env.ADMIN_USERNAME &&
password === process.env.ADMIN_PASSWORD &&
process.env.ENABLE_ENV_ADMIN === "true"
) {
// Create admin login
}if (
username === process.env.ADMIN_USERNAME &&
password === process.env.ADMIN_PASSWORD &&
process.env.ENABLE_ENV_ADMIN === "true"
) {
// Create admin login
}This may be useful during development or emergency recovery.
However, it is dangerous in production.
If the environment credentials are leaked, an attacker may receive full admin access.
My Solution
The environment admin is disabled by default.
ENABLE_ENV_ADMIN=falseENABLE_ENV_ADMIN=falseThe application should refuse to start when it is enabled in production.
if (
process.env.NODE_ENV === "production" &&
process.env.ENABLE_ENV_ADMIN === "true"
) {
throw new Error(
"Environment admin must not be enabled in production"
);
}if (
process.env.NODE_ENV === "production" &&
process.env.ENABLE_ENV_ADMIN === "true"
) {
throw new Error(
"Environment admin must not be enabled in production"
);
}A real production admin should normally be stored in the database with:
- A securely hashed password
- An active or inactive status
- Audit history
- Role management
- Password-reset support
12. Password Database Theft
What Is the Attack?
If the database is stolen and passwords are stored as plain text, every password becomes visible immediately.
Unsafe example:
user.password = password;user.password = password;My Solution
Passwords should be hashed with bcrypt.
import bcrypt from "bcrypt";
const saltRounds = 12;
const passwordHash = await bcrypt.hash(
password,
saltRounds
);import bcrypt from "bcrypt";
const saltRounds = 12;
const passwordHash = await bcrypt.hash(
password,
saltRounds
);Store the hash:
await User.create({
name,
user_name,
password: passwordHash,
role: "user",
});await User.create({
name,
user_name,
password: passwordHash,
role: "user",
});During login:
const passwordMatches = await bcrypt.compare(
password,
user.password
);
if (!passwordMatches) {
return res.status(401).json({
message: "Invalid username or password",
});
}const passwordMatches = await bcrypt.compare(
password,
user.password
);
if (!passwordMatches) {
return res.status(401).json({
message: "Invalid username or password",
});
}A password hash cannot simply be converted back into the original password.
Strong password rules should also be added.
For example:
const isStrongPassword =
password.length >= 12 &&
/[A-Z]/.test(password) &&
/[a-z]/.test(password) &&
/[0-9]/.test(password);
if (!isStrongPassword) {
return res.status(400).json({
message:
"Password must contain at least 12 characters, including uppercase, lowercase, and a number",
});
}const isStrongPassword =
password.length >= 12 &&
/[A-Z]/.test(password) &&
/[a-z]/.test(password) &&
/[0-9]/.test(password);
if (!isStrongPassword) {
return res.status(400).json({
message:
"Password must contain at least 12 characters, including uppercase, lowercase, and a number",
});
}13. SQL Injection
What Is the Attack?
SQL injection happens when user input is directly added to an SQL query.
Unsafe example:
const query =
`SELECT * FROM users WHERE user_name = '${username}'`;const query =
`SELECT * FROM users WHERE user_name = '${username}'`;An attacker may enter specially created text that changes the SQL command.
My Solution
I use Sequelize methods instead of building SQL strings manually.
const user = await User.findOne({
where: {
user_name: username,
},
});const user = await User.findOne({
where: {
user_name: username,
},
});Sequelize safely handles the value as a query parameter.
If a raw query is required, use replacements.
const users = await sequelize.query(
`
SELECT id, name, user_name, role
FROM users
WHERE user_name = :username
`,
{
replacements: {
username,
},
type: QueryTypes.SELECT,
}
);const users = await sequelize.query(
`
SELECT id, name, user_name, role
FROM users
WHERE user_name = :username
`,
{
replacements: {
username,
},
type: QueryTypes.SELECT,
}
);Do not directly place request values inside SQL strings.
14. Missing Security Headers
What Is the Attack?
Missing HTTP security headers can make some browser attacks easier.
My Solution
I use Helmet in the Express backend.
import helmet from "helmet";
app.use(
helmet({
contentSecurityPolicy: false,
})
);import helmet from "helmet";
app.use(
helmet({
contentSecurityPolicy: false,
})
);Helmet adds several useful security headers.
The backend only returns JSON and does not serve the Vue HTML files, so I disabled CSP on the API server.
However, the Vue frontend should have its own CSP on the server that hosts it.
For example, an Nginx configuration may include:
add_header Content-Security-Policy "
default-src 'self';
script-src 'self';
style-src 'self' 'unsafe-inline';
img-src 'self' data:;
connect-src 'self' https://api.example.com;
object-src 'none';
frame-ancestors 'none';
" always;add_header Content-Security-Policy "
default-src 'self';
script-src 'self';
style-src 'self' 'unsafe-inline';
img-src 'self' data:;
connect-src 'self' https://api.example.com;
object-src 'none';
frame-ancestors 'none';
" always;The policy must be tested because some frontend libraries may need additional allowed sources.
15. Unsafe Error Messages
What Is the Attack?
Detailed errors may reveal internal information.
Unsafe response:
{
"message": "MySQL users table failed at column password"
}{
"message": "MySQL users table failed at column password"
}This tells an attacker about the database structure.
Another unsafe response is:
{
"message": "JWT verification failed because secret_v2 was incorrect"
}{
"message": "JWT verification failed because secret_v2 was incorrect"
}My Solution
Return simple messages to the client:
return res.status(500).json({
message: "An internal server error occurred",
});return res.status(500).json({
message: "An internal server error occurred",
});Log the detailed error only on the server:
console.error("Authentication error:", error);console.error("Authentication error:", error);Do not log:
- Passwords
- JWT values
- Cookie values
- Payment information
- Private patient information
Recommended Environment Configuration
Development Environment
NODE_ENV=development
PORT=3000
CORS_ORIGINS=http://localhost:5173
JWT_SECRET_CURRENT=(replace-with-a-long-random-development-secret)
JWT_SECRET_PREVIOUS=
JWT_EXPIRES_IN=1h
ENABLE_ENV_ADMIN=false
ADMIN_USERNAME=
ADMIN_PASSWORD=NODE_ENV=development
PORT=3000
CORS_ORIGINS=http://localhost:5173
JWT_SECRET_CURRENT=(replace-with-a-long-random-development-secret)
JWT_SECRET_PREVIOUS=
JWT_EXPIRES_IN=1h
ENABLE_ENV_ADMIN=false
ADMIN_USERNAME=
ADMIN_PASSWORD=Frontend:
VITE_API_BASE_URL=http://localhost:3000VITE_API_BASE_URL=http://localhost:3000In development, the cookie configuration can use:
secure: falsesecure: falseThis allows local HTTP development.
Production Environment
NODE_ENV=production
PORT=3000
CORS_ORIGINS=https://app.example.com
JWT_SECRET_CURRENT=production-secret-from-a-secret-manager
JWT_SECRET_PREVIOUS=
JWT_EXPIRES_IN=1h
ENABLE_ENV_ADMIN=falseNODE_ENV=production
PORT=3000
CORS_ORIGINS=https://app.example.com
JWT_SECRET_CURRENT=production-secret-from-a-secret-manager
JWT_SECRET_PREVIOUS=
JWT_EXPIRES_IN=1h
ENABLE_ENV_ADMIN=falseFrontend:
VITE_API_BASE_URL=https://api.example.comVITE_API_BASE_URL=https://api.example.comIn production:
secure: truesecure: trueThe production website must use HTTPS.
Real secrets should be stored in a secure secret-management system, not directly inside the source code.
Recommended Express Setup
The middleware order matters.
import express from "express";
import cookieParser from "cookie-parser";
import cors from "cors";
import helmet from "helmet";
const app = express();
app.use(
helmet({
contentSecurityPolicy: false,
})
);
app.use(cors(corsOptions));
app.use(express.json());
app.use(
express.urlencoded({
extended: true,
})
);
app.use(cookieParser());
app.use(verifyRequestOrigin);
app.use("/auth", authenticationRoutes);
app.use("/dashboard", dashboardRoutes);
app.use("/patients", patientRoutes);import express from "express";
import cookieParser from "cookie-parser";
import cors from "cors";
import helmet from "helmet";
const app = express();
app.use(
helmet({
contentSecurityPolicy: false,
})
);
app.use(cors(corsOptions));
app.use(express.json());
app.use(
express.urlencoded({
extended: true,
})
);
app.use(cookieParser());
app.use(verifyRequestOrigin);
app.use("/auth", authenticationRoutes);
app.use("/dashboard", dashboardRoutes);
app.use("/patients", patientRoutes);Important points:
- Helmet adds security headers.
- CORS checks the frontend origin.
- Express parses the request body.
- Cookie Parser makes
req.cookiesavailable. - Origin validation helps protect sensitive requests.
- Authentication and authorization middleware protect the routes.
Recommended Login Controller
export const login = async (
req: Request,
res: Response
): Promise<void> => {
try {
const { username, password } = req.body;
if (!username || !password) {
res.status(400).json({
message:
"Username and password are required",
});
return;
}
const result =
await authenticationService.login(
username,
password
);
res.cookie(
"token",
result.token,
authCookieOptions
);
res.status(200).json({
user: {
id: result.id,
username: result.username,
role: result.role,
},
});
} catch (error) {
console.error("Login error:", error);
res.status(401).json({
message: "Invalid username or password",
});
}
};export const login = async (
req: Request,
res: Response
): Promise<void> => {
try {
const { username, password } = req.body;
if (!username || !password) {
res.status(400).json({
message:
"Username and password are required",
});
return;
}
const result =
await authenticationService.login(
username,
password
);
res.cookie(
"token",
result.token,
authCookieOptions
);
res.status(200).json({
user: {
id: result.id,
username: result.username,
role: result.role,
},
});
} catch (error) {
console.error("Login error:", error);
res.status(401).json({
message: "Invalid username or password",
});
}
};The JWT is not returned inside the JSON response.
The frontend receives only safe user information.
Recommended Authentication Middleware
import {
NextFunction,
Request,
Response,
} from "express";
export interface AuthUser {
id: number;
username: string;
role: "admin" | "user";
authType?: "db_user" | "env_admin";
tokenVersion?: number;
}
export interface AuthRequest extends Request {
user?: AuthUser;
}
export const authMiddleware = async (
req: AuthRequest,
res: Response,
next: NextFunction
) => {
try {
const token = req.cookies?.token;
if (!token) {
return res.status(401).json({
message: "Unauthorized",
});
}
const decoded =
verifyAuthToken(token) as AuthUser;
if (
decoded.authType === "env_admin" &&
process.env.ENABLE_ENV_ADMIN === "true" &&
process.env.NODE_ENV !== "production"
) {
req.user = {
id: 0,
username: decoded.username,
role: "admin",
authType: "env_admin",
};
return next();
}
const user = await User.findByPk(decoded.id);
if (!user) {
return res.status(401).json({
message: "User not found",
});
}
if (!user.is_active) {
return res.status(403).json({
message: "Account is inactive",
});
}
if (
decoded.tokenVersion !== undefined &&
decoded.tokenVersion !== user.token_version
) {
return res.status(401).json({
message: "Session is no longer valid",
});
}
req.user = {
id: user.id as number,
username: user.user_name,
role: user.role,
authType: "db_user",
};
return next();
} catch (error) {
return res.status(401).json({
message: "Invalid or expired session",
});
}
};import {
NextFunction,
Request,
Response,
} from "express";
export interface AuthUser {
id: number;
username: string;
role: "admin" | "user";
authType?: "db_user" | "env_admin";
tokenVersion?: number;
}
export interface AuthRequest extends Request {
user?: AuthUser;
}
export const authMiddleware = async (
req: AuthRequest,
res: Response,
next: NextFunction
) => {
try {
const token = req.cookies?.token;
if (!token) {
return res.status(401).json({
message: "Unauthorized",
});
}
const decoded =
verifyAuthToken(token) as AuthUser;
if (
decoded.authType === "env_admin" &&
process.env.ENABLE_ENV_ADMIN === "true" &&
process.env.NODE_ENV !== "production"
) {
req.user = {
id: 0,
username: decoded.username,
role: "admin",
authType: "env_admin",
};
return next();
}
const user = await User.findByPk(decoded.id);
if (!user) {
return res.status(401).json({
message: "User not found",
});
}
if (!user.is_active) {
return res.status(403).json({
message: "Account is inactive",
});
}
if (
decoded.tokenVersion !== undefined &&
decoded.tokenVersion !== user.token_version
) {
return res.status(401).json({
message: "Session is no longer valid",
});
}
req.user = {
id: user.id as number,
username: user.user_name,
role: user.role,
authType: "db_user",
};
return next();
} catch (error) {
return res.status(401).json({
message: "Invalid or expired session",
});
}
};Recommended Role Middleware
import {
NextFunction,
Response,
} from "express";
type UserRole = "admin" | "user";
export const authorizeRoles =
(...allowedRoles: UserRole[]) =>
(
req: AuthRequest,
res: Response,
next: NextFunction
) => {
if (!req.user) {
return res.status(401).json({
message: "Unauthorized",
});
}
if (!allowedRoles.includes(req.user.role)) {
return res.status(403).json({
message:
"You do not have permission to access this API",
});
}
return next();
};import {
NextFunction,
Response,
} from "express";
type UserRole = "admin" | "user";
export const authorizeRoles =
(...allowedRoles: UserRole[]) =>
(
req: AuthRequest,
res: Response,
next: NextFunction
) => {
if (!req.user) {
return res.status(401).json({
message: "Unauthorized",
});
}
if (!allowedRoles.includes(req.user.role)) {
return res.status(403).json({
message:
"You do not have permission to access this API",
});
}
return next();
};Example route:
router.get(
"/stats",
authMiddleware,
authorizeRoles("admin"),
getStats
);router.get(
"/stats",
authMiddleware,
authorizeRoles("admin"),
getStats
);Recommended Frontend Authentication Setup
The frontend does not store the JWT.
const apiClient = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL,
withCredentials: true,
headers: {
"Content-Type": "application/json",
},
});const apiClient = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL,
withCredentials: true,
headers: {
"Content-Type": "application/json",
},
});Pinia stores only the user object:
state: () => ({
user: null,
isInitialized: false,
})state: () => ({
user: null,
isInitialized: false,
})Login:
async login(form) {
const data = await authService.login(form);
if (!data?.user) {
throw new Error("Invalid login response");
}
this.user = data.user;
this.isInitialized = true;
return data;
}async login(form) {
const data = await authService.login(form);
if (!data?.user) {
throw new Error("Invalid login response");
}
this.user = data.user;
this.isInitialized = true;
return data;
}Restore the session:
async fetchMe() {
try {
const data = await authService.me();
this.user = data.user;
this.isInitialized = true;
return data.user;
} catch {
this.user = null;
this.isInitialized = true;
return null;
}
}async fetchMe() {
try {
const data = await authService.me();
this.user = data.user;
this.isInitialized = true;
return data.user;
} catch {
this.user = null;
this.isInitialized = true;
return null;
}
}Logout:
async logout() {
try {
await authService.logout();
} finally {
this.user = null;
this.isInitialized = true;
}
}async logout() {
try {
await authService.logout();
} finally {
this.user = null;
this.isInitialized = true;
}
}Final Security Summary
This Blog explain how to reduces the risk of JWT theft through JavaScript.
However, HttpOnly cookies are only one part of application security.
The blog also help to get understand about below attecks may occur:
- XSS
- CSRF
- Brute-force login attempts
- Weak JWT secrets
- Role manipulation
- IDOR attacks
- Stolen token replay
- SQL injection
- Unsafe error messages
- Incorrect CORS settings
- Dangerous environment admin access
The most important rule is simple: Never trust the frontend.
The frontend can hide pages and buttons, but the backend must verify every sensitive request.
For a clinic management application, the backend should always check:
Who is the user?
Is the user active?
What is the user’s latest role?
Can the user perform this action?
Can the user access this specific record?Who is the user?
Is the user active?
What is the user’s latest role?
Can the user perform this action?
Can the user access this specific record?Using HttpOnly cookies, database-backed permission checks, secure environment settings, HTTPS, rate limiting, CORS restrictions, and careful input handling creates a much stronger security design.