This document outlines the security strategy, implementation details, and checklists for the portfolio CMS ecosystem backend.
The following sequence illustrates a user logging in and rotating their sessions using Refresh Token Rotation (RTR).
sequenceDiagram
autonumber
actor Admin as Admin Browser
participant API as Express API
participant DB as MongoDB Atlas
Note over Admin, DB: Authentication Flow (Login)
Admin->>API: POST /api/auth/login { email, password }
API->>DB: Query User (by email)
DB-->>API: User Record with passwordHash
API->>API: bcrypt.compare(password, passwordHash)
API->>API: Generate Access Token (JWT 15m)
API->>API: Generate Refresh Token (JWT 7d)
API->>API: Hash Refresh Token (SHA256)
API->>DB: Save refreshTokenHash, refreshTokenExpiresAt, lastLoginAt
DB-->>API: Acknowledged
API-->>Admin: HTTP 200 + Access Token in JSON + Rotated HttpOnly Cookie
Note over Admin, DB: Session Extension (Refresh Token Rotation)
Admin->>API: POST /api/auth/refresh (with cookie)
API->>API: verifyRefreshToken(token)
API->>DB: Query User (by decoded sub)
DB-->>API: User Record with refreshTokenHash
API->>API: Compare sha256(incoming token) == user.refreshTokenHash
API->>API: Validate decoded.tokenVersion == user.refreshTokenVersion
alt Verification Successful
API->>API: Increment user.refreshTokenVersion (+1)
API->>API: Generate new Access Token and Refresh Token
API->>API: Hash new Refresh Token
API->>DB: Save new refreshTokenHash and refreshTokenExpiresAt
DB-->>API: Acknowledged
API-->>Admin: HTTP 200 + New Access Token + New Rotated HttpOnly Cookie
else Hijack/Reuse Detected
API->>API: Increment user.refreshTokenVersion (+1) to invalidate session family
API->>API: Clear user.refreshTokenHash and user.refreshTokenExpiresAt
API->>DB: Save User
DB-->>API: Acknowledged
API-->>Admin: HTTP 403 Session Compromised (Force Log In)
end
- Access Tokens: Short-lived JSON Web Tokens (JWT) signed with a strong secret (
JWT_ACCESS_SECRET).- Lifetime: 15 minutes.
- Claims:
{ "sub": "userId", "role": "admin", "email": "admin@example.com", "tokenVersion": 0 } - Storage: Sent in the response JSON envelope body (
meta.accessToken). Access tokens are never stored in cookies. In client applications, they are kept in-memory and are never written tolocalStorageorsessionStorageto mitigate Cross-Site Scripting (XSS) extraction. - Verification: Verified using the
JWT_ACCESS_SECRET. The middleware verifies both the signature and check that the token'stokenVersionmatches the user'srefreshTokenVersionin the database to allow immediate token revocation.
To prevent replay attacks and detect refresh token hijacking:
- Lifetime: 7 days.
- Claims:
{ sub: userId, tokenVersion }. - Database Hardening: Raw refresh tokens are never stored. The database stores
refreshTokenHash(SHA256 hash of the token),refreshTokenVersion, andrefreshTokenExpiresAt. - Verification:
- The incoming refresh token is verified against the signature.
- The incoming token is hashed using SHA256.
- The database matches this hash against
user.refreshTokenHash. - The database verifies that the current timestamp is less than
user.refreshTokenExpiresAt. - The
decoded.tokenVersionmatchesuser.refreshTokenVersion.
- Hijack Protection: If a token version mismatch or hash mismatch occurs, it suggests token theft. The server immediately increments
user.refreshTokenVersionto invalidate the entire session family, clears the token hashes, saves the record, and returns a403 Forbiddenresponse forcing re-authentication.
flowchart TD
Request[POST /api/auth/forgot-password] --> Find{User exists?}
Find -- No --> Generic[Return HTTP 202Generic Success]
Find -- Yes --> Token[Generate crypto.randomBytes 32 Token]
Token --> Hash[Calculate SHA256 Hash of Token]
Hash --> Save[Save passwordResetTokenHash and passwordResetExpiresAt 15m in User]
Save --> Email[Console Log Mock Email with raw token]
Email --> Generic
Reset[POST /api/auth/reset-password] --> Verify{Token matches hash & not expired?}
Verify -- No --> Err[Return HTTP 400 Invalid Reset Token]
Verify -- Yes --> Update[Hash new password with bcrypt cost 12]
Update --> Invalidate[Increment user.refreshTokenVersion +1]
Invalidate --> SaveDB[Clear reset token fields, save new passwordHash, clear refresh hashes]
SaveDB --> Success[Return HTTP 204 Success]
- Rotated refresh tokens are stored in an HttpOnly cookie named
refreshToken. - HttpOnly: Restricts client-side scripts from reading the cookie, neutralizing XSS exfiltration.
- Secure: Restricts cookie transmission to encrypted HTTPS connections only (disabled in
test/developmentenvironments, strictly enabled inproduction). - SameSite:
development->Lax(simplifies cross-origin localhost development testing)production->Strict(guards against CSRF vectors by blocking the cookie from third-party cross-site requests).
- Path: Constrained to
/api/auth/refreshto restrict the browser from transmitting it on other API endpoint routes. - maxAge: 7 days (
7 * 24 * 60 * 60 * 1000).
We enforce rate limit restrictions via express-rate-limit:
- Authentication: Limit to 5 requests per 15 minutes per IP (
authLimiterapplied to/login,/forgot-password, and/reset-password). - Contact Forms: Limit to 20 requests per 15 minutes per IP (
contactLimiterapplied to/contact). - Global API Calls: Limit to 100 requests per 15 minutes per IP (
apiLimiterapplied globally on/api/*).
- CORS checks request origin against a strict allowlist containing:
http://localhost:5173(Vite portfolio)http://localhost:5174(Vite dashboard)- Production configurations
env.FRONTEND_URLandenv.ADMIN_URL.
- Credentials Allowed: Enabled (
credentials: true) to support cookie transmissions. - Wildcards (
*) are prohibited when credentials are enabled.
Helmet header guards enforce:
- default-src:
['self'] - img-src:
['self', 'https:', 'data:'] - script-src:
['self'] - style-src:
['self', 'unsafe-inline'] - xssFilter: Enabled.
- noSniff: Enabled (
nosniff). - frameguard: Enabled (
SAMEORIGIN).
To transition this single-admin CMS to a high-security admin surface, future phases will support:
- Time-based One-Time Passwords (TOTP) using standard authenticator apps (e.g. Google Authenticator).
- Backup recovery codes stored as secure single-use hashes.
- Endpoint validation forcing MFA challenges for settings updates or user password resets.
The file upload pipeline employs strict validation boundaries and integrates with Cloudinary to deliver secure, optimized media delivery with zero local storage footprint.
flowchart TD
Browser[Browser / Admin Client] -->|POST /api/uploads/:type| Auth{Authenticated & Admin?}
Auth -- No --> FailAuth[Return 401/403 Error]
Auth -- Yes --> Multer[Multer Memory Storage]
Multer -->|Populates req.file| SizeCheck{Size <= limit?}
SizeCheck -- No --> FailSize[Return 400 Bad Request]
SizeCheck -- Yes --> FormatCheck{Mime & Ext Allowed?}
FormatCheck -- No --> FailFormat[Return 415 Unsupported Media Type]
FormatCheck -- Yes --> FolderCheck{Whitelisted Folder?}
FolderCheck -- No --> FailFolder[Return 400 Bad Request]
FolderCheck -- Yes --> Cloudinary[Cloudinary Stream Upload]
Cloudinary -->|Secure HTTPS URL| Response[Success Response 200 with Metadata]
Validation is executed in a fail-fast order (File exists -> MIME -> Extension -> File size -> Whitelisted folder).
The allowed configurations by category are:
- PROFILE (
portfolio/profile): JPEG, PNG, WebP only. Maximum size: 5 MB. - PROJECTS (
portfolio/projects): JPEG, PNG, WebP, GIF only. Maximum size: 10 MB. - CERTIFICATES (
portfolio/certificates): JPEG, PNG, WebP, PDF only. Maximum size: 10 MB. - BLOGS (
portfolio/blogs): JPEG, PNG, WebP only. Maximum size: 8 MB. - RESUMES (
portfolio/resumes): PDF only. Maximum size: 5 MB. - TESTIMONIALS (
portfolio/testimonials): JPEG, PNG, WebP only. Maximum size: 5 MB.
To prevent server hijack, cross-site scripting (XSS), or injection vectors, the following file formats are explicitly blacklisted and return a 415 Unsupported Media Type response:
- Vector Formats:
svg(blocked to prevent XML External Entity (XXE) and script injections inside SVG images). - Executables:
exe,bat,apk,dll,sh,php,html,js(blocked to prevent malicious script uploads). - Archive Types:
zip,rar(blocked to prevent zip bomb extraction vectors).
- Direct Streaming: Files are parsed using
multer.memoryStorage()and piped directly intocloudinary.uploader.upload_streamusing buffer stream endpoints. Local disk writes (multer.diskStorage(), temp files) are strictly prohibited. - TLS Delivery: Cloudinary configuration forces
secure: trueglobally, ensuring all generated delivery URLs utilize HTTPS. - Transformations and Metadata Stripping: Upload options apply
flags: "strip_profile", stripping out camera, color profile, and geolocation EXIF metadata. Images are auto-optimized usingquality: "auto"andfetch_format: "auto"with scale limits (crop: "limit").
Before executing a deletion query on Cloudinary:
- The
publicIdparameter is checked to confirm it starts with a whitelisted folder (e.g.portfolio/profile/). - Paths containing directory traversal signatures (such as
../) are rejected with a400 Bad Request(INVALID_PUBLIC_ID), preventing arbitrary file deletions outside our whitelisted folders. - Asset presence is verified on Cloudinary, returning
404if the asset has already been deleted or is missing.