strapi-security-suite
v0.4.0
Published
All-in-one authentication and session security plugin for Strapi v5
Maintainers
Readme
🛡️ Strapi Security Suite
The admin security plugin that takes your sessions personally — and now scales horizontally.
One plugin. Auto-logout. Single-session enforcement. Token revocation. Heartbeat. Multi-pod-safe. Built for Strapi v5. Backed by your existing database. Zero new infrastructure.
🤔 What Is This?
Imagine a bouncer at a nightclub. But the nightclub is your Strapi admin panel, the bouncer has a perfect memory, never sleeps, and will physically escort your idle admins out the door after 30 minutes of doing nothing.
That's this plugin. And in v0.4, the bouncer works across every door of every venue in the franchise — not just the one he's standing at.
🔐 Admin logs in (Pod A)
|
| 👀 Activity tracked → DB (visible to all pods)
| 🫀 Client heartbeat fires every 30s on mouse/keyboard
|
| 😴 Admin walks away from desk...
|
| ⏰ 30 minutes pass...
|
| 👑 Watcher leader (could be Pod C) marks session revoked → DB
|
🚪 Next request to ANY pod → BOOM. Logged out. Cookies cleared. Token dead.
No coin-flip. No "depends which pod you hit." Just security.✨ Features at a Glance
| Feature | What It Does | Vibe |
| -------------------------- | ---------------------------------------------------------- | -------------------- |
| ⏰ Auto-Logout | Kicks idle admins after configurable minutes | "Use it or lose it" |
| 🫀 Activity Heartbeat | Form-filling counts as activity (no spurious idle-logouts) | "We see you typing" |
| 🚫 Single-Session Lock | One admin = one session. Across every pod. | "No shadow clones" |
| 💀 Session Revocation | Per-sessionId. Cluster-wide. Instant. | "Ghosts get ghosted" |
| 🌐 Multi-Pod Safe | DB-backed state, leader-elected watcher | "OpenShift-ready" |
| 🔑 Password Policy | Expiry + non-reusable passwords (configurable) | "Rotate or regret" |
| ⚙️ Admin UI | Settings panel right inside Strapi | "Click, don't code" |
| 🛡️ Input Validation | Server-side validation on every settings save | "Trust nobody" |
🚀 Quick Start
Step 1: Install it
yarn add strapi-security-suiteStep 2: Enable it
Add this to your config/plugins.js (or .ts):
module.exports = ({ env }) => ({
'strapi-security-suite': {
enabled: true,
},
});Step 3: Restart Strapi
yarn developThree new tables — sss_admin_sessions, sss_login_locks, sss_watcher_leases — are auto-created by Strapi on first boot. No manual migration. No new dependencies. No config required.
Step 4: Find it
Go to Settings → Global → Security Suite
That's it. You're done. Go get a coffee. ☕
🌐 Why Multi-Pod-Safe Matters
In v0.3 the plugin kept its session state — last-active timestamps, revoked emails, login locks — in per-pod in-memory Maps and Sets. On a single-pod deployment, fine. On a horizontally-scaled OpenShift / Kubernetes deployment with multiple replicas behind a load balancer, those data structures lived independently per pod and that broke every guarantee the plugin made:
- An admin's requests round-robined across N pods → each pod saw only ~1/N of their activity → some pod's watcher decided they'd been idle 30 min and revoked them while they were actively typing.
- Pod A revoked a session. The next request landed on Pod B → no entry → no force-reload signal → revocation became a 1/N-probability event.
- A logged-out admin's bearer kept working on every pod that hadn't seen the logout, until the JWT expired.
- Two concurrent logins for the same email hit different pods → both pods saw empty maps → both succeeded. Single-session enforcement only worked on a single pod, which is exactly when you don't need it.
v0.4 moves all of that state into the database. Revocation issued on any pod is visible to every other pod on the next request. The watcher is leader-elected so only one pod cluster-wide actually runs the 5-second tick. Login locks are atomic across pods via SELECT … FOR UPDATE. No Redis. No new infra. Your existing Postgres / MySQL / SQLite handles it.
🖼️ The Admin Panel
Once installed, you get a beautiful settings page with two panels:
┌───────────────────────────────────────────────────────┐
│ 🛡️ Security & Session Settings │
├───────────────────────────┬───────────────────────────┤
│ │ │
│ 🕐 SESSION MANAGEMENT │ 🔑 PASSWORD MANAGEMENT │
│ │ │
│ Auto Logout Time: [30] │ Password Control: [ON] │
│ (minutes) │ │
│ │ Expiry Days: [30] │
│ Multi-Session │ │
│ Control: [ON] │ Non-Reusable: [ON] │
│ │ │
├───────────────────────────┴───────────────────────────┤
│ [ 💾 Save Settings ] │
└───────────────────────────────────────────────────────┘Settings are stored in a single-type DB record. Change a value, hit save, it takes effect immediately. No restarts. No config files.
🧠 How It Actually Works
🔗 The Middleware Pipeline
When any request hits your Strapi server, it passes through 5 security checkpoints (middlewares), in this exact order:
🌐 Incoming Request
│
▼
1. 🐣 seedUserInfos
│ "Decode the JWT. Pull userId AND sessionId. Hydrate ctx.state."
│
▼
2. 🔍 interceptRenewToken
│ "Logging out? Mark this sessionId revoked in the DB. Cluster-wide."
│
▼
3. 👣 trackActivity
│ "If this sessionId is revoked → 403 + clear cookies. Else stamp lastActiveAt
│ to the DB (write-coalesced to once per 30s)."
│
▼
4. ☠️ rejectRevokedTokens
│ "Belt-and-suspenders revocation check. Sets app.admin.tk header so the
│ frontend force-reloads. Calls sessionManager.invalidateRefreshToken."
│
▼
5. 🚫 preventMultipleSessions (on login only)
"Acquire cross-pod login lock. Refuse with 409 if another active session
for this email exists anywhere in the cluster."⏱️ The Auto-Logout Watcher (Leader-Elected)
Every pod runs a setInterval every 5 seconds. Inside the tick:
🔄 Every 5 seconds, every pod:
│
├─→ acquireWatcherLease() (atomic UPDATE on sss_watcher_leases)
│ │
│ ├─→ Got it? I'm the leader. Continue.
│ └─→ Someone else has it? Skip the rest. (1 cheap DB query, done.)
│
│ (Only the leader runs the body below)
│
├─→ pruneExpiredLocks() (clean up sss_login_locks where lockedUntil < now)
│
├─→ listIdleSessions({ idleThresholdMs })
│ SELECT FROM sss_admin_sessions
│ WHERE revoked_at IS NULL AND last_active_at < (now - threshold)
│
└─→ For each idle session:
• UPDATE sss_admin_sessions SET revoked_at = NOW() WHERE id = ?
• sessionManager('admin').invalidateRefreshToken(userId)
• Log itIf the leader pod dies, its lease (15s TTL) expires and another pod claims it on the next tick. Worst-case revocation lag during failover: 15 seconds.
🫀 The Activity Heartbeat
A new admin-side hook listens for mousemove, keydown, scroll, click, touchstart (passive). On any event, throttled to once per 30 seconds, it fires POST /strapi-security-suite/heartbeat. The middleware chain treats it like any other authenticated request, so trackActivity updates lastActiveAt.
This means a user filling a long form for 25 minutes — generating zero other HTTP traffic — is not auto-logged-out. Form-filling is correctly recognized as activity.
🖥️ The Frontend Interceptor
window.fetch is patched to watch for the app.admin.tk response header:
🌐 Admin makes any API call
│
▼
👀 Check response headers for 'app.admin.tk'
│
YES → 🚨 FORCED LOGOUT 🚨 window.location.reload()
│
NO → ✅ Normal response. Continue working.🗃️ DB Schema (auto-created on boot)
| Table | Purpose | Key columns |
| -------------------- | ----------------------------- | -------------------------------------------------------------- |
| sss_admin_sessions | One row per admin session | session_id (unique), email, last_active_at, revoked_at |
| sss_login_locks | Cross-pod login lock | email (unique), locked_until |
| sss_watcher_leases | Watcher leader-election lease | name (unique), holder, expires_at |
Hidden from the content-manager and content-type-builder via pluginOptions. Strapi creates them on first boot the same way the existing security-settings singleType is created — no manual migration step.
📂 Project Structure
strapi-security-suite/
📁 admin/src/ ← Admin panel (React)
│ 📄 index.js Plugin entry + fetch interceptor + heartbeat install
│ 📄 heartbeat.js Throttled activity-heartbeat client hook
│ 📄 constants.js API paths, header names, heartbeat throttle
│ 📄 pluginId.js Plugin ID constant
│ 📁 components/Initializer.jsx Plugin lifecycle init
│ 📁 pages/
│ │ 📄 App.jsx Router
│ │ 📄 HomePage.jsx Settings UI
│ 📁 translations/en.json i18n strings
│
📁 server/src/ ← Server-side (Node.js)
│ 📄 index.js Plugin entry point
│ 📄 register.js Middleware registration phase
│ 📄 bootstrap.js Permissions + settings seeding + watcher start
│ 📄 destroy.js Releases watcher lease, stops interval
│ 📄 constants.js ⭐ ALL magic values live here
│ │
│ 📁 controllers/
│ │ 📄 adminSecurityController.js GET/POST settings + POST heartbeat
│ │
│ 📁 services/
│ │ 📄 state.js The DB-backed state core (replaces the in-memory globals)
│ │ 📄 autoLogoutChecker.js Leader-elected background watcher
│ │
│ 📁 middlewares/
│ │ 📄 seedUserInfos.js Decode JWT, extract userId + sessionId
│ │ 📄 interceptRenewToken.js Revoke session on logout (DB-backed)
│ │ 📄 trackActivity.js Persist lastActiveAt (write-coalesced)
│ │ 📄 rejectRevokedTokens.js Force-reload signal + cookie clear
│ │ 📄 preventMultipleSessions.js Cross-pod login lock + active-session check
│ │
│ 📁 policies/has-admin-permission.js
│ │
│ 📁 utils/
│ │ 📄 errors.js PluginError, ValidationError, AuthorizationError
│ │ 📄 clearSessionCookies.js Clears koa.sess, koa.sess.sig, refresh + JWT cookies
│ │
│ 📁 content-types/
│ │ 📁 security-settings/ Plugin config (singleType)
│ │ 📁 admin-session/ Per-session activity + revocation
│ │ 📁 login-lock/ Cross-pod login lock
│ │ 📁 watcher-lease/ Watcher leader-election lease
│ │
│ 📁 routes/index.js Admin-typed routes with policies
│
📁 tests/ ← Vitest test suite (66 tests)
│ 📁 helpers/
│ │ 📄 strapi-fake.js sqlite :memory: + Knex harness
│ │ 📄 mock-strapi.js Mock-based ctx + state helpers
│ 📁 server/
│ │ 📄 state.test.js 15 tests — touch, revocation, listIdle, hasActiveSession
│ │ 📄 state.concurrency.test.js 9 tests — multi-pod login lock + watcher lease
│ │ 📄 seedUserInfos.test.js 6 tests — JWT decode, ctx hydration
│ │ 📄 trackActivity.test.js 4 tests — touch, revocation rejection
│ │ 📄 rejectRevokedTokens.test.js 4 tests — header signal, sessionManager
│ │ 📄 preventMultipleSessions.test.js 8 tests — login lock flow
│ │ 📄 interceptRenewToken.test.js 3 tests — logout revocation
│ │ 📄 autoLogoutChecker.test.js 8 tests — leader election + idle revocation
│ │ 📄 adminSecurityController.test.js 9 tests — heartbeat + settings validation🔧 Configuration Schema
All settings live in a single-type content-type in the database:
{
"autoLogoutTime": 30,
"multipleSessionsControl": true,
"passwordExpiryDays": 30,
"nonReusablePassword": true,
"enablePasswordManagement": true
}| Field | Type | Default | What It Does |
| -------------------------- | --------- | ------- | ---------------------------------------- |
| autoLogoutTime | integer | 30 | Minutes of inactivity before auto-logout |
| multipleSessionsControl | boolean | true | Block concurrent sessions for same admin |
| passwordExpiryDays | integer | 30 | Days before password must be changed |
| nonReusablePassword | boolean | true | Prevent reuse of previous passwords |
| enablePasswordManagement | boolean | true | Master switch for password features |
🧪 API Endpoints
All routes are admin-typed (Strapi handles auth automatically):
| Method | Path | Auth | Permission | Description |
| ------ | --------------------------------------- | -------- | ---------------- | --------------------------------- |
| POST | /strapi-security-suite/heartbeat | 🔒 Admin | — | Activity keep-alive (returns 204) |
| GET | /strapi-security-suite/admin/settings | 🔒 Admin | view-configs | Read security settings |
| POST | /strapi-security-suite/admin/settings | 🔒 Admin | manage-configs | Update security settings |
🔐 Permissions
| Permission | What It Allows |
| ---------------------------------------------- | ------------------------ |
| plugin::strapi-security-suite.access | Access the settings page |
| plugin::strapi-security-suite.view-configs | Read security settings |
| plugin::strapi-security-suite.manage-configs | Modify security settings |
💡 Recommended Host-App Configuration
The plugin works out of the box with Strapi defaults, but for tighter revocation latency consider lowering the access-token TTL in your host app's config/admin.js:
module.exports = ({ env }) => ({
auth: {
secret: env('ADMIN_JWT_SECRET'),
options: {
expiresIn: '2m', // ← short access tokens, refreshed transparently by the admin frontend
},
},
});With a 2-minute access-token TTL, a revoked admin loses access within ~2 minutes even if no other request is made (next refresh attempt fails because the refresh token is also invalidated). With the default 30-minute TTL, revocation is enforced on the next request the admin makes (via the app.admin.tk force-reload signal) — instant for active admins, up to 30 min for an idle one whose tab is open.
🛠️ Development
yarn install # Install dependencies
yarn build # Build the plugin
yarn watch # Auto-rebuild on changes
yarn lint # ESLint
yarn lint:fix # ESLint --fix
yarn format # Prettier
yarn format:check # Check formatting
yarn verify # Verify plugin exports
yarn test # Run the full test suite (66 tests)
yarn test:watch # Vitest in watch mode
yarn test:coverage # Coverage reportThe state-service tests run against a real sqlite :memory: DB via Knex — they exercise the actual SQL the plugin issues, including the SELECT … FOR UPDATE paths and ON CONFLICT behaviors. Two simulated pods cover the cross-pod concurrency cases.
🔮 Roadmap
| Feature | Status | | ----------------------------- | ----------------- | | ⏰ Auto-Logout | ✅ Shipped (v0.1) | | 🚫 Single-Session Enforcement | ✅ Shipped (v0.1) | | 💀 Session Revocation | ✅ Shipped (v0.1) | | ⚙️ Admin Settings UI | ✅ Shipped (v0.1) | | 🌐 Multi-Pod-Safe State | ✅ Shipped (v0.4) | | 🫀 Activity Heartbeat | ✅ Shipped (v0.4) | | 🧪 Test Suite (66 tests) | ✅ Shipped (v0.4) | | 🔑 Password Expiry | 🚧 In Development | | 🔄 Non-Reusable Passwords | 🚧 In Development | | 📝 Admin Activity Logs | 🔜 Planned | | 📊 Security Dashboard | 🔜 Planned | | 👊 Brute Force Detection | 🔜 Planned |
🗣️ Real Talk
"We installed this and now our interns can't share logins anymore." — A CTO, probably
"Our admin panel feels like it judges us now. I love it." — That one developer who actually cares
"I left my desk for coffee and came back logged out. Respect." — Someone who now understands security
"We scaled to 8 pods on OpenShift and the plugin… just kept working. Sessions, revocations, locks — all consistent." — A platform engineer in v0.4
👥 Author
LPIX-11 — Orange / Sonatel
⚖️ License
MIT — Do whatever you want. Just don't blame us if you turn off all the features and get breached. That's on you.
💡 Philosophy
Security should be:
- Correct under load — Multi-pod deployments shouldn't degrade the security model into a coin flip.
- Cheap to operate — DB-backed state with write-coalescing. No Redis. No new infra.
- Unforgiving — Idle? Gone. Revoked? Dead. Duplicated? Blocked.
- Mildly judgmental — This plugin will side-eye your stale sessions.
"The meta-principle: make the right thing the default thing. Discipline compounds. Shortcuts compound too, just in the wrong direction."
