

How to build a secure, production‑ready login & data‑access flow that mitigates the risk exposed by the California student‑records breach.
TL;DR – Follow the steps below to get a minimal but fully‑functional API (Python FastAPI or Node/TypeScript Express) that:
- Verifies credentials with bcrypt (password hashing).
- Issues short‑lived JWT access tokens + refresh tokens.
- Enforces role‑based access (student, teacher, admin).
- Applies security middleware (Helmet, rate‑limiting, CORS, logging).
- Shows how to configure secrets safely and what to check before going live.
<a name="step-1-prerequisites"></a>
| Item | Why you need it | Minimum version |
|---|---|---|
| Python | Runtime for the FastAPI example | 3.9+ |
| Node.js | Runtime for the Express/TS example | 14+ (LTS) |
| Package manager | Install dependencies | pip (Python) or npm / yarn (Node) |
| Git | Clone the starter repo (optional) | any |
| IDE / Editor | Write & debug code | VS Code, PyCharm, WebStorm, etc. |
| Database (for demo) | Store users & student records | PostgreSQL 13+ or SQLite (dev only) |
| API testing tool | Verify endpoints | curl, HTTPie, Postman, or Insomnia |
| (Optional) Docker | Containerise for prod | Docker Engine 20.10+ |
Note: The code works with either a real PostgreSQL instance or an in‑memory SQLite DB for quick testing. Switching only requires changing the
DATABASE_URLenv var.
<a name="step-2-installation-and-setup"></a>
# Choose one language or keep both
git clone https://github.com/icarax/secured-student-records.git
cd secured-student-records
# 1️⃣ Create a virtual environment
python -m venv venv
source venv/bin/activate # Windows: venv\Scripts\activate
# 2️⃣ Install dependencies
pip install --upgrade pip
pip install fastapi uvicorn[standard] sqlalchemy psycopg2-binary bcrypt python-jose[cryptography] passlib[bcrypt] python-dotenv email-validator
# 3️⃣ Initialise Alembic (optional, for migrations)
alembic init alembic # if you want DB migrations later
# 1️⃣ Initialise a new Node project
npm init -y
# (or yarn init -y)
# 2️⃣ Install core packages
npm install express helmet cors rate-limit-flexible bcryptjs jsonwebtoken dotenv pg sequelize sequelize-typescript
# 3️⃣ Install TypeScript & typings (if using TS)
npm install --save-dev typescript @types/node @types/express @types/jsonwebtoken @types/bcryptjs ts-node-dev
npx tsc --init # creates tsconfig.json
.env)Create a .env at the project root (never commit this file!).
# ==== Shared ====
NODE_ENV=development # or "production"
PORT=8000 # API port (adjust per language)
# ==== Database ====
# Use PostgreSQL for prod; SQLite for quick local test
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/studentdb
# For SQLite (dev only): DATABASE_URL=sqlite:///./test.db
# ==== JWT ====
ACCESS_TOKEN_SECRET=super-secret-access-key-change-me
REFRESH_TOKEN_SECRET=super-secret-refresh-key-change-me
ACCESS_TOKEN_EXPIRES_IN=15m # short-lived access token
REFRESH_TOKEN_EXPIRES_IN=7d # longer-lived refresh token
# ==== Security ====
BCRYPT_SALT_ROUNDS=12
RATE_LIMIT_WINDOW_MS=60000 # 1 minute
RATE_LIMIT_MAX_REQUESTS=100 # per IP per window
Tip: In production, store these values in a secret manager (AWS Secrets Manager, HashiCorp Vault, GCP Secret Manager, etc.) and inject them as env vars at container start‑up.
<a name="step-3-basic-implementation"></a>
Below are complete, copy‑paste‑ready files for each language.
Both implementations share the same API contract:
| Endpoint | Method | Description |
|---|---|---|
/auth/register | POST | Create a new user (hash password, assign role). |
/auth/login | POST | Verify credentials → return access_token & refresh_token. |
/auth/refresh | POST | Exchange a valid refresh token for a new access token. |
/students | GET | Protected – return a list of student records (role‑filtered). |
/students/:id | GET | Protected – return a single student record if allowed. |
main.py# --------------------------------------------------------------
# 📁 main.py – FastAPI secure login & student‑record API
# --------------------------------------------------------------
import os
from datetime import datetime, timedelta
from typing import List, Optional
from fastapi import FastAPI, Depends, HTTPException, status, Security
from fastapi.security import OAuth2PasswordRequestForm, OAuth2PasswordBearer, SecurityScopes
from jose import JWTError, jwt
from passlib.context import CryptContext
from pydantic import BaseModel, EmailStr, Field
from sqlalchemy import Column, Integer, String, Boolean, create_engine
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker, Session
from dotenv import load_dotenv
# --------------------------------------------------------------
# 1️⃣ Load environment variables
# --------------------------------------------------------------
load_dotenv() # reads .env into os.environ
DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./test.db")
ACCESS_TOKEN_SECRET = os.getenv("ACCESS_TOKEN_SECRET")
REFRESH_TOKEN_SECRET = os.getenv("REFRESH_TOKEN_SECRET")
ACCESS_TOKEN_EXPIRES_MIN = int(os.getenv("ACCESS_TOKEN_EXPIRES_IN", "15").replace("m", ""))
REFRESH_TOKEN_EXPIRES_DAYS = int(os.getenv("REFRESH_TOKEN_EXPIRES_IN", "7").replace("d", ""))
BCRYPT_SALT_ROUNDS = int(os.getenv("BCRYPT_SALT_ROUNDS", "12"))
# --------------------------------------------------------------
# 2️⃣ DB setup (SQLAlchemy)
# --------------------------------------------------------------
engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False} if "sqlite" in DATABASE_URL else {})
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base = declarative_base()
class User(Base):
__tablename__ = "users"
id = Column(Integer, primary_key=True, index=True)
email = Column(String, unique=True, index=True, nullable=False)
hashed_password = Column(String, nullable=False)
is_active = Column(Boolean, default=True)
role = Column(String, nullable=False) # "student", "teacher", "admin"
class StudentRecord(Base):
__tablename__ = "student_records"
id = Column(Integer, primary_key=True, index=True)
first_name = Column(String, nullable=False)
last_name = Column(String, nullable=False)
dob = Column(String) # YYYY-MM-DD for demo
grade = Column(String)
# Create tables (in prod use Alembic migrations)
Base.metadata.create_all(bind=engine)
# --------------------------------------------------------------
# 3️⃣ Password hashing
# --------------------------------------------------------------
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def get_password_hash(password: str) -> str:
return pwd_context.hash(password, rounds=BCRYPT_SALT_ROUNDS)
def verify_password(plain: str, hashed: str) -> bool:
return pwd_context.verify(plain, hashed)
# --------------------------------------------------------------
# 4️⃣ JWT helpers
# --------------------------------------------------------------
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None):
to_encode = data.copy()
expire = datetime.utcnow() + (expires_delta or timedelta(minutes=15))
to_encode.update({"exp": expire, "type": "access"})
return jwt.encode(to_encode, ACCESS_TOKEN_SECRET, algorithm="HS256")
def create_refresh_token(data: dict):
to_encode = data.copy()
expire = datetime.utcnow() + timedelta(days=REFRESH_TOKEN_EXPIRES_DAYS)
to_encode.update({"exp": expire, "type": "refresh"})
return jwt.encode(to_encode, REFRESH_TOKEN_SECRET, algorithm="HS256")
def decode_token(token: str, secret: str):
try:
payload = jwt.decode(token, secret, algorithms=["HS256"])
return payload
except JWTError:
return None
# --------------------------------------------------------------
# 5️⃣ Pydantic models (request/response)
# --------------------------------------------------------------
class UserCreate(BaseModel):
email: EmailStr
password: str = Field(..., min_length=8)
role: str = Field(..., regex="^(student|teacher|admin)$")
class TokenResponse(BaseModel):
access_token: str
refresh_token: str
token_type: str = "bearer"
class StudentOut(BaseModel):
id: int
first_name: str
last_name: str
dob: str
grade: str
class Config:
orm_mode = True
# --------------------------------------------------------------
# 6️⃣ FastAPI app & security dependencies
# --------------------------------------------------------------
app = FastAPI(title="Secured Student Records API")
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/login", scopes={
"student": "Read own student data",
"teacher": "Read/Write student data",
"admin": "Full access"
})
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
def get_current_user(
security_scopes: SecurityScopes,
token: str = Depends(oauth2_scheme),
db: Session = Depends(get_db)
):
if security_scopes.scopes:
authenticate_value = f'Bearer scope="{security_scopes.scope_str}"'
else:
authenticate_value = "Bearer"
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAuthorized,
detail="Could not validate credentials",
headers={"WWW-Authenticate": authenticate_value},
)
payload = decode_token(token, ACCESS_TOKEN_SECRET)
if payload is None or payload.get("type") != "access":
raise credentials_exception
email: str = payload.get("sub")
if email is None:
raise credentials_exception
user = db.query(User).filter(User.email == email).first()
if user is None:
raise credentials_exception
# Scope check (simple role‑based)
for scope in security_scopes.scopes:
if scope not in user.role:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not enough permissions",
headers={"WWW-Authenticate": authenticate_value},
)
return user
# --------------------------------------------------------------
# 7️⃣ Endpoints
# --------------------------------------------------------------
@app.post("/auth/register", response_model=TokenResponse, status_code=201)
def register(user_in: UserCreate, db: Session = Depends(get_db)):
# Prevent duplicate email
if db.query(User).filter(User.email == user_in.email).first():
raise HTTPException(status_code=400, detail="Email already registered")
hashed_pw = get_password_hash(user_in.password)
user = User(email=user_in.email, hashed_password=hashed_pw, role=user_in.role)
db.add(user)
db.commit()
db.refresh(user)
access_token = create_access_token({"sub": user.email})
refresh_token = create_refresh_token({"sub": user.email})
return {"access_token": access_token, "refresh_token": refresh_token}
@app.post("/auth/login", response_model=TokenResponse)
def login(form_data: OAuth2PasswordRequestForm = Depends(), db: Session = Depends(get_db)):
user = db.query(User).filter(User.email == form_data.username).first()
if not user or not verify_password(form_data.password, user.hashed_password):
raise HTTPException(status_code=400, detail="Incorrect email or password")
access_token = create_access_token({"sub": user.email})
refresh_token = create_refresh_token({"sub": user.email})
return {"access_token": access_token, "refresh_token": refresh_token}
@app.post("/auth/refresh", response_model=TokenResponse)
def refresh_token(refresh_token: str, db: Session = Depends(get_db)):
payload = decode_token(refresh_token, REFRESH_TOKEN_SECRET)
if not payload or payload.get("type") != "refresh":
raise HTTPException(status_code=401, detail="Invalid refresh token")
email = payload.get("sub")
user = db.query(User).filter(User.email == email).first()
if not user or not user.is_active:
raise HTTPException(status_code=401, detail="User not found or inactive")
new_access = create_access_token({"sub": user.email})
new_refresh = create_refresh_token({"sub": user.email})
return {"access_token": new_access, "refresh_token": new_refresh}
@app.get("/students", response_model=List[StudentOut], dependencies=[Depends(Security(get_current_user, scopes=["student"]))])
def list_students(db: Session = Depends(get_db)):
# In a real app you would filter by school, etc.
return db.query(StudentRecord).all()
@app.get("/students/{student_id}", response_model=StudentOut, dependencies=[Depends(Security(get_current_user, scopes=["student"]))])
def get_student(student_id: int, db: Session = Depends(get_db)):
record = db.query(StudentRecord).filter(StudentRecord.id == student_id).first()
if not record:
raise HTTPException(status_code=404, detail="Student not found")
return record
# --------------------------------------------------------------
# 8️⃣ Run with: uvicorn main:app --reload
# --------------------------------------------------------------
python-dotenv.CryptContext).python-jose.src/server.ts// --------------------------------------------------------------
// 📁 src/server.ts – Express + TypeScript secure login & student‑record API
// --------------------------------------------------------------
import "dotenv/config"; // loads .env into process.env
import express, { Request, Response, NextFunction } from "express";
import helmet from "helmet";
import cors from "cors";
import rateLimit from "express-rate-limit";
import bcrypt from "bcryptjs";
import jwt, { JwtPayload } from "jsonwebtoken";
import { Sequelize, DataTypes, Model } from "sequelize";
// --------------------------------------------------------------
// 1️⃣ Configuration from env
// --------------------------------------------------------------
const PORT = Number(process.env.PORT) || 8000;
const NODE_ENV = process.env.NODE_ENV || "development";
const ACCESS_TOKEN_SECRET = process.env.ACCESS_TOKEN_SECRET!;
const REFRESH_TOKEN_SECRET = process.env.REFRESH_TOKEN_SECRET!;
const ACCESS_EXPIRES_IN = process.env.ACCESS_TOKEN_EXPIRES_IN || "15m";
const REFRESH_EXPIRES_IN = process.env.REFRESH_TOKEN_EXPIRES_IN || "7d";
const BCRYPT_SALT_ROUNDS = Number(process.env.BCRYPT_SALT_ROUNDS) || 12;
const DATABASE_URL = process.env.DATABASE_URL || "sqlite:./test.db";
// --------------------------------------------------------------
// 2️⃣ Sequelize setup (SQLite for demo; swap to Postgres easily)
// --------------------------------------------------------------
const sequelize = new Sequelize(DATABASE_URL, {
logging: false, // set to console.log in dev if you want SQL output
});
class User extends Model {
public id!: number;
public email!: string;
public hashedPassword!: string;
public isActive!: boolean;
public role!: "student" | "teacher" | "admin";
}
User.init(
{
email: { type: DataTypes.STRING, unique: true, allowNull: false },
hashedPassword: { type: DataTypes.STRING, allowNull: false },
isActive: { type: DataTypes.BOOLEAN, defaultValue: true },
role: { type: DataTypes.ENUM("student", "teacher", "admin"), allowNull: false },
},
{ sequelize, modelName: "user" }
);
class StudentRecord extends Model {
public id!: number;
public firstName!: string;
public lastName!: string;
public dob!: string; // YYYY-MM-DD
public grade!: string;
}
StudentRecord.init(
{
firstName: { type: DataTypes.STRING, allowNull: false },
lastName: { type: DataTypes.STRING, allowNull: false },
dob: { type: DataTypes.STRING, allowNull: false },
grade: { type: DataTypes.STRING, allowNull: false },
},
{ sequelize, modelName: "studentRecord" }
);
// Sync models (in prod use migrations)
sequelize.sync({ alter: true }).then(() => console.log("DB synced"));
// --------------------------------------------------------------
// 3️⃣ Helpers
// --------------------------------------------------------------
const hashPassword = async (plain: string) => await bcrypt.hash(plain, BCRYPT_SALT_ROUNDS);
const comparePassword = async (plain: string, hashed: string) => await bcrypt.compare(plain, hashed);
interface JwtPayloadExt extends JwtPayload {
sub: string;
type: "access" | "refresh";
}
const signAccessToken = (payload: object) =>
jwt.sign({ ...payload, type: "access" }, ACCESS_TOKEN_SECRET, { expiresIn: ACCESS_EXPIRES_IN });
const signRefreshToken = (payload: object) =>
jwt.sign({ ...payload, type: "refresh" }, REFRESH_TOKEN_SECRET, { expiresIn: REFRESH_EXPIRES_IN });
const verifyToken = (token: string, secret: string): JwtPayloadExt | null => {
try {
const payload = jwt.verify(token, secret) as JwtPayloadExt;
return payload.type === "access" || payload.type === "refresh" ? payload : null;
} catch {
return null;
}
};
// --------------------------------------------------------------
// 4️⃣ Express app & middleware
// --------------------------------------------------------------
const app = express();
app.use(helmet()); // security headers
app.use(cors({ origin: process.env.CORS_ORIGIN ?? true })); // adjust as needed
app.use(express.json());
// Rate limiting (basic IP‑based)
const limiter = rateLimit({
windowMs: Number(process.env.RATE_LIMIT_WINDOW_MS) || 60000, // 1 min
max: Number(process.env.RATE_LIMIT_MAX_REQUESTS) || 100, // limit each IP
standardHeaders: true,
legacyHeaders: false,
});
app.use(limiter);
// --------------------------------------------------------------
// 5️⃣ Auth middleware (role‑based)
// --------------------------------------------------------------
interface AuthRequest extends Request {
user?: {
id: number;
email: string;
role: "student" | "teacher" | "admin";
};
}
const authenticate = (requiredRoles: ("student" | "teacher" | "admin")[]) => {
return async (req: AuthRequest, res: Response, next: NextFunction) => {
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith("Bearer ")) {
return res.status(401).json({ error: "Missing Bearer token" });
}
const token = authHeader.split(" ")[1];
const payload = verifyToken(token, ACCESS_TOKEN_SECRET);
if (!payload) {
return res.status(401).json({ error: "Invalid or expired access token" });
}
const user = await User.findOne({ where: { email: payload.sub } });
if (!user || !user.isActive) {
return res.status(401).json({ error: "User not found or inactive" });
}
if (!requiredRoles.includes(user.role as any)) {
return res.status(403).json({ error: "Insufficient permissions" });
}
req.user = {
id: user.id,
email: user.email,
role: user.role as any,
};
next();
};
};
// --------------------------------------------------------------
// 6️⃣ Routes
// --------------------------------------------------------------
app.post("/auth/register", async (req, res) => {
const { email, password, role } = req.body;
if (!email || !password || !role) {
return res.status(400).json({ error: "email, password, and role are required" });
}
const exists = await User.findOne({ where: { email } });
if (exists) {
return res.status(400).json({ error: "Email already registered" });
}
const hashed = await hashPassword(password);
const user = await User.create({ email, hashedPassword: hashed, role: role as any });
const access = signAccessToken({ sub: user.email });
const refresh = signRefreshToken({ sub: user.email });
res.status(201).json({ access_token: access, refresh_token: refresh });
});
app.post("/auth/login", async (req, res) => {
const { email, password } = req.body;
const user = await User.findOne({ where: { email } });
if (!user || !(await comparePassword(password, user.hashedPassword))) {
return res.status(400).json({ error: "Invalid email or password" });
}
const access = signAccessToken({ sub: user.email });
const refresh = signRefreshToken({ sub: user.email });
res.json({ access_token: access, refresh_token: refresh });
});
app.post("/auth/refresh", async (req, res) => {
const { refresh_token } = req.body;
if (!refresh_token) return res.status(400).json({ error: "refresh_token required" });
const payload = verifyToken(refresh_token, REFRESH_TOKEN_SECRET);
if (!payload || payload.type !== "refresh") {
return res.status(401).json({ error: "Invalid refresh token" });
}
const user = await User.findOne({ where: { email: payload.sub } });
if (!user || !user.isActive) {
return res.status(401).json({ error: "User not found or inactive" });
}
const newAccess = signAccessToken({ sub: user.email });
const newRefresh = signRefreshToken({ sub: user.email });
res.json({ access_token: newAccess, refresh_token: newRefresh });
});
app.get(
"/students",
authenticate(["student", "teacher", "admin"]),
async (_req: AuthRequest, res: Response) => {
const records = await StudentRecord.findAll();
res.json(records);
}
);
app.get(
"/students/:id",
authenticate(["student", "teacher", "admin"]),
async (req: AuthRequest, res: Response) => {
const record = await StudentRecord.findByPk(Number(req.params.id));
if (!record) return res.status(404).json({ error: "Student not found" });
res.json(record);
}
);
// --------------------------------------------------------------
// 7️⃣ Error‑handling middleware (catch‑all)
// --------------------------------------------------------------
app.use((err: any, _req: Request, res: Response, _next: NextFunction) => {
console.error(err);
res.status(500).json({ error: "Internal server error" });
});
// --------------------------------------------------------------
// 8️⃣ Start server
// --------------------------------------------------------------
app.listen(PORT, () => {
console.log(`🚀 Server running in ${NODE_ENV} mode on http://localhost:${PORT}`);
});
authenticate) that can be stacked per‑route.Tip: If you prefer plain JavaScript, drop the type annotations and remove the
ts-node-devdev dependency; the file works as.jsas well.
<a name="step-4-configuration"></a>
| Area | What to set | Example ( .env) |
|---|---|---|
| Database | Connection string for PostgreSQL (prod) or SQLite (dev) | DATABASE_URL=postgresql://app_user:strong_pass@db-host:5432/student_records |
| JWT Secrets | Strong, random strings (≥32 bytes) – never reuse the same secret for access & refresh | ACCESS_TOKEN_SECRET=$(openssl rand -base64 32)<br>REFRESH_TOKEN_SECRET=$(openssl rand -base64 32) |
| Token lifetimes | Short access token (5‑15 min) + longer refresh token (hours‑days) – adjust per risk tolerance | ACCESS_TOKEN_EXPIRES_IN=10m<br>REFRESH_TOKEN_EXPIRES_IN=1d |
| Password hashing | Bcrypt cost factor – higher = slower but safer; 12 is a good baseline | BCRYPT_SALT_ROUNDS=12 |
| Rate limiting | Protect login/endpoints from brute force | RATE_LIMIT_WINDOW_MS=60000<br>RATE_LIMIT_MAX_REQUESTS=20 (login) |
| CORS | Restrict origins to your frontend domain(s) | CORS_ORIGIN=https://portal.example.com |
| Logging | Forward logs to stdout/stderr for container collection; optionally configure a logger like Winston or Loguru. | (no env var – configure in code) |
| Secrets management (prod) | Inject via platform (K8s Secrets, AWS ECS task role, GCP Secret Manager, etc.) – do not commit .env. | N/A |
Loading the config
Both examples use dotenv (python-dotenv or dotenv npm) to read .env only in non‑production environments. In production you should unset dotenv loading and rely on the platform’s secret injection.
<a name="step-5-common-patterns"></a>
https://app.example.com/reset?token=….Issue a new refresh token each time you exchange an old one (as shown). This limits the window a stolen refresh token can be abused.
Define scopes per role (student:read, teacher:read:write, admin:*). The authenticate middleware can check requiredScopes against user.scopes claim inside the JWT.
sequelize migration:generate) or TypeORM migrations.<a name="step-6-troubleshooting"></a>
| Symptom | Likely Cause | Fix |
|---|---|---|
500 Internal Server Error – Secret must be a string or buffer | ACCESS_TOKEN_SECRET or REFRESH_TOKEN_SECRET missing or empty in env. | Verify .env is loaded (echo $ACCESS_TOKEN_SECRET) and contains a non‑empty string. |
401 Unauthorized – Invalid or expired access token | Token expired, wrong secret, or token type mismatch. | Check system clock (NTP sync). Ensure you’re using the access secret when verifying access tokens. |
400 Bad Request – Email already registered on registration | Unique constraint violation (DB already has the email). | Use a different email or call the login endpoint instead. |
429 Too Many Requests – Rate limit triggered | Too many login attempts from same IP in a short window. | Slow down, or increase RATE_LIMIT_MAX_REQUESTS for dev; in prod, consider IP whitelisting or CAPTCHA. |
bcrypt error: Illegal salt | Mismatched bcrypt version or corrupted hash stored. | Re‑hash passwords (e.g., via a migration) after upgrading bcrypt library. |
Sequelize Connection refused | Postgres not reachable (wrong host/port, credentials, or service down). | Verify DATABASE_URL, ensure DB container/service is running, and that security groups allow traffic. |
TypeScript Cannot find module 'dotenv' | Missing dev dependency or using node directly instead of ts-node-dev. | Run npm install dotenv and start with npm run dev (which uses ts-node-dev). |
Alembic No such revision | Migration history out of sync after manual DB changes. | Either reset dev DB (dropdb + recreate) or generate a new migration that reflects current models. |
General debugging tips
export LOG_LEVEL=debug (Python) or DEBUG=express:* (Node).curl -v to see raw request/response headers.<a name="step-7-production-checklist"></a>
| ✅ Item | Why it matters | How to verify |
|---|---|---|
| HTTPS everywhere | Prevents token sniffing & MITM. | Use a TLS termination proxy (NGINX, Envoy, ALB) with valid cert from Let’s Encrypt or corporate CA. |
| Secrets not in code/repo | Avoids leakage via git history. | Run git log -p --grep="SECRET" – should return nothing. Use secret manager or platform‑provided env vars. |
| Short-lived access tokens | Limits damage if token is stolen. | Check ACCESS_TOKEN_EXPIRES_IN ≤ 15 min. |
| Refresh token rotation + storage | Mitigates replay attacks. | Verify /auth/refresh issues a new refresh token and invalidates the old one (implement a refresh‑token blacklist or DB flag). |
| Rate limiting on auth endpoints | Thwarts credential stuffing & brute force. | Test with hey or wrk – ensure 429 after threshold. |
| Input validation & ORM protection | Stops injection & malformed data. | Attempt SQL injection (' OR 1=1--) – should return validation error, not DB error. |
| Security headers (Helmet) | Reduces XSS, clickjacking, MIME sniffing. | Inspect response headers (curl -I) for Content-Security-Policy, X-Frame-Options, etc. |
| CORS restricted to trusted origins | Prevents unauthorized web clients from calling your API. | Verify `Access-Control |
Source: Security Week AI
Follow ICARAX for more AI insights and tutorials.
