DEV Community

Daniel Ioni
Daniel Ioni

Posted on

Building Revocable Sessions and Device Security for the MyZubster Metaverse

Building Revocable Sessions and Device Security for the MyZubster Metaverse
MyZubster is evolving from a collection of community tools into a persistent digital ecosystem: the Neon Plaza metaverse, verified identities, social login, community missions, a marketplace, and the Zorgax knowledge layer.
That evolution introduces an important requirement: authentication can no longer be treated as a simple “generate a JWT and forget about it” feature.
A persistent world needs persistent identity, but persistent identity also needs:

  • real logout;
  • session revocation;
  • device visibility;
  • expiration enforcement;
  • privacy-preserving metadata;
  • account recovery;
  • protection against stolen or replayed tokens;
  • a controlled migration path for existing users. We have now implemented the first major security slice of this work under MYZ-71 — Authentication, sessions and account security. The implementation is available in the draft pull request: MYZ-71: Add revocable sessions and device security controls The pull request remains a draft. It has not been merged or deployed to production yet. The original authentication limitation The existing authentication model used signed JSON Web Tokens. A simplified token looked conceptually like this: { "userId": "USER_ID", "username": "daniel", "role": "user", "iat": 1789970000, "exp": 1790574800 } The server could validate the signature and expiration date, but it could not easily answer several important questions:
  • Is this login still active?
  • Did the user explicitly log out?
  • Was the device revoked from another device?
  • Was the account disabled after the token was issued?
  • Which browser created the token?
  • Is this token associated with a known login session?
  • Should this specific token be rejected even though its signature is valid? A stateless JWT remains valid until it expires unless additional server-side state is introduced. That behavior is insufficient for an account connected to a persistent metaverse character, social identity, marketplace activity, or potentially sensitive account actions. The new model: JWT plus server-side session state We kept JWTs as signed access credentials, but bound every newly issued JWT to a persistent server-side session. The token now contains a session identifier: { "userId": "USER_ID", "username": "daniel", "role": "user", "sid": "4cb9c3a1-8d39-4f2d-8a91-82c9556abc91", "jti": "48ab2bd5-b4fa-426a-af42-3e24758e0434", "iat": 1789970000, "exp": 1790574800 } The important new claims are:
  • sid: identifies the persistent authentication session;
  • jti: uniquely identifies the issued JWT. The JWT signature is still validated, but authentication does not stop there. The server also checks that the session referenced by sid:
  • exists;
  • belongs to the authenticated user;
  • has not expired;
  • has not been revoked. This creates a hybrid model: Incoming request │ ▼ Extract bearer token or secure cookie │ ▼ Verify JWT signature and expiration │ ▼ Read sid from the token │ ▼ Load the server-side AuthSession │ ├── missing ─────────► reject ├── revoked ─────────► reject ├── expired ─────────► reject └── active ──────────► continue A cryptographically valid JWT is therefore no longer sufficient when its associated server session has been revoked. The AuthSession persistence model We added a dedicated MongoDB model for authentication sessions. Conceptually, each session contains: { sessionId: String, userId: ObjectId, userAgent: String, ipHash: String, createdAt: Date, lastSeenAt: Date, expiresAt: Date, revokedAt: Date, revokedReason: String } Unique session identifiers Every authentication event creates a cryptographically random UUID: const sessionId = crypto.randomUUID(); The session identifier is stored in MongoDB and included in the signed JWT as sid. Expiration and TTL cleanup Every session receives an explicit expiresAt timestamp. MongoDB also has a TTL index that eventually removes expired documents. However, MongoDB TTL deletion is asynchronous. An expired document may remain physically present for a short period. For this reason, authentication queries do not rely exclusively on the TTL cleanup process. They explicitly require: { revokedAt: null, expiresAt: { $gt: new Date() } } This means the session becomes unusable immediately after expiration, even if MongoDB has not deleted the document yet. Privacy-preserving IP metadata Raw IP addresses are not stored. Instead, when an IP address is available, the server creates an HMAC-SHA256 digest: const digest = crypto .createHmac('sha256', SESSION_IP_HASH_SECRET) .update(ipAddress) .digest('hex'); This allows future security analysis—such as identifying changes between login contexts—without persisting the original address in clear text. This is an intentional privacy decision. Authentication telemetry should not become an unnecessary database of raw location information. Device metadata The current implementation stores a bounded user-agent string: userAgent: String(req.get('user-agent') || 'Unknown device').slice(0, 500) The frontend presents this value as the device description. Future work can parse it into a more human-readable representation such as: Chrome on Windows Safari on iPhone Firefox on Linux The raw value is currently retained because it preserves information without adding another dependency or parsing service. Last-seen throttling Updating lastSeenAt on every request would generate unnecessary database writes. The middleware therefore updates the value only if the previous activity timestamp is older than five minutes. Conceptually: if (Date.now() - lastSeenAt > FIVE_MINUTES) { await AuthSession.updateOne( { sessionId, revokedAt: null }, { $set: { lastSeenAt: new Date() } } ); } This provides useful account activity information while avoiding a write operation for every API request. Token delivery: secure cookie and bearer compatibility The new implementation supports two token sources:
  • an Authorization: Bearer header;
  • the myzubster_session cookie. The cookie is configured with: { httpOnly: true, secure: production, sameSite: 'lax', path: '/', maxAge: sessionTtl } Why HttpOnly matters An HttpOnly cookie cannot be read directly through browser JavaScript. This reduces exposure when a client-side script is compromised. It does not eliminate every XSS risk, but it prevents simple token extraction through code such as: document.cookie Why bearer tokens are still accepted MyZubster already has frontend code and users relying on tokens stored by earlier authentication flows. Immediately removing bearer-token support would invalidate every active session and could break several existing clients. The current release is therefore transitional: Existing client └── Authorization: Bearer

New client
└── HttpOnly myzubster_session cookie
Both methods are accepted while the migration is in progress.
The long-term direction is to remove unnecessary browser-accessible authentication tokens and rely on secure cookies for the web application.
Controlled legacy-token migration
Older JWTs do not contain an sid claim.
During the migration period, these tokens can still be accepted. Strict enforcement can later be enabled with:
REQUIRE_SERVER_SESSION=true
When strict mode is active, a token without a server-side session identifier is rejected.
The rollout sequence is designed to avoid an uncontrolled forced logout:

  1. deploy the session-aware server;
  2. issue server-backed sessions for every new login;
  3. verify the new session collection and TTL index;
  4. test cookie behavior in production;
  5. allow legacy JWTs to age out;
  6. enable REQUIRE_SERVER_SESSION=true;
  7. remove obsolete browser token handling in a later cleanup. Feature flags are especially useful for authentication migrations because rollback must remain possible. New account-security endpoints The server now exposes four primary endpoints. Retrieve the authenticated user GET /api/auth/me This returns the current authenticated account and its associated public character information. List active sessions GET /api/auth/me/sessions The response contains only active, non-expired sessions belonging to the current user. Example: { "success": true, "request_id": "bf7a957e-1669-4432-a9a4-e686cb70cc07", "sessions": [ { "id": "4cb9c3a1-8d39-4f2d-8a91-82c9556abc91", "current": true, "device": "Mozilla/5.0 ...", "createdAt": "2026-09-27T08:00:00.000Z", "lastSeenAt": "2026-09-27T09:00:00.000Z", "expiresAt": "2026-10-04T08:00:00.000Z" } ] } The server determines current by comparing the listed session with the sid from the request token. Revoke a device session DELETE /api/auth/me/sessions/:sessionId The server verifies that the session belongs to the authenticated user before revoking it. The update is scoped by both values: { userId, sessionId, revokedAt: null } This prevents a user from revoking another account’s session merely by obtaining or guessing its identifier. Revocation records: { revokedAt: new Date(), revokedReason: 'user-revoked' } If the user revokes the session currently being used, the server also clears the session cookie. Logout POST /api/auth/logout Logout is intentionally idempotent. This means all of the following should still result in the browser cookie being cleared:
  8. a valid active token;
  9. an expired token;
  10. a malformed token;
  11. an already revoked token;
  12. a missing server-side session. The route is deliberately not protected by the normal authentication middleware. If the middleware rejected an expired token before reaching the logout handler, the browser could become unable to clear its own invalid cookie. The logout flow instead attempts token verification, revokes the session when possible, and always clears the cookie. Structured authentication errors Authentication errors previously risked exposing implementation-specific JWT messages. The middleware now returns stable public errors: { "success": false, "request_id": "5fe11522-c804-4907-a4d1-0f459f94ab2a", "error": { "code": "AUTH_SESSION_REVOKED", "message": "Session expired or revoked" } } Examples of public error codes include: AUTH_TOKEN_MISSING AUTH_TOKEN_INVALID AUTH_TOKEN_EXPIRED AUTH_SESSION_REVOKED Raw library errors remain server-side. Request IDs Each error response contains a request_id. The server accepts a caller-provided ID only if it satisfies a restricted format and length. Otherwise, it creates a new UUID. This gives support and observability systems a stable correlation value without returning stack traces or cryptographic details to the client. Social authentication integration Persistent sessions are now issued for:
  13. password registration;
  14. password login;
  15. Google authentication;
  16. GitHub authentication;
  17. Facebook authentication. The social OAuth exchange no longer returns only a disconnected JWT. It also creates a server-side session and sets the secure cookie. During integration testing, two existing social-identity problems were found and corrected. GitHub nested-profile persistence A GitHub login could attempt to persist an undefined nested public profile snapshot through Mongoose. The update was changed to use explicit nested-field assignment and to write publicSnapshot only when a real value is available. This avoids an invalid Mongoose cast during GitHub authentication. Facebook accounts without a public email Some Facebook accounts do not provide an email address. MyZubster already had the concept of a deterministic private relay identity for this case. The integration tests were updated to reflect the intended behavior instead of incorrectly requiring every Facebook account to return an email. The relay identity remains an internal identifier. It is not presented as a verified personal email address. The React account-security interface The frontend now includes: /account/security The page provides:
  18. authenticated account information;
  19. all active server sessions;
  20. a current-device indicator;
  21. device or user-agent information;
  22. creation timestamp;
  23. last activity timestamp;
  24. expiration timestamp;
  25. remote session revocation;
  26. current-session termination;
  27. complete logout. The page is linked from:
  28. the authenticated account card on the MyZubster home interface;
  29. the Neon Plaza metaverse top bar. Frontend API layer A dedicated API module handles account-session requests: getCurrentAccount(); getAuthSessions(); revokeAuthSession(sessionId); logoutCurrentSession(); clearBrowserAuth(); Requests use same-origin credentials: fetch(path, { credentials: 'same-origin', headers: { ...bearerFallback } }); The bearer header remains present only as a migration fallback. Remote revocation behavior When another device is revoked:
  30. the frontend asks the user for confirmation;
  31. it sends the session-specific DELETE request;
  32. the server verifies ownership and revokes the session;
  33. the frontend removes the device from the active list;
  34. the revoked device is rejected on its next authenticated request. Current-session termination When the current session is terminated:
  35. the server marks the session as revoked;
  36. the secure cookie is cleared;
  37. browser-side legacy tokens are removed;
  38. the user is redirected through authentication again. The local cleanup removes historical keys such as: myzubster-token token accessToken myzubster-user myzubster-identity-provider myzubster-metaverse-character-id This prevents an older browser credential from being silently reused after logout. Deployment routing and indexing protection The new route is explicitly configured for the React single-page application. Vercel routes: /account/security to the frontend entry point. The response also includes: X-Robots-Tag: noindex, nofollow Account-security pages should never appear in search results, cached snippets, or public navigation indexes. The route exists in both the root Vercel configuration and the standalone frontend configuration. Both JSON configuration files were parsed and validated after the update. Testing performed Authentication work requires more than verifying that a login button returns HTTP 200. Backend tests Five focused backend suites currently pass: authSessionService authMiddlewareSessions authSessionController socialAuthCallback socialIdentityService Results: 5 suites passed 24 tests passed The tests cover:
  39. session creation;
  40. JWT sid binding;
  41. cookie token extraction;
  42. bearer token extraction;
  43. active session validation;
  44. revoked session rejection;
  45. expired token handling;
  46. structured error responses;
  47. session listing;
  48. session revocation;
  49. idempotent logout;
  50. malformed-cookie logout;
  51. Google identity integration;
  52. GitHub identity integration;
  53. Facebook identity integration;
  54. persistent metaverse characters;
  55. persistent authentication sessions. The social integration tests use an in-memory MongoDB instance, so they exercise persistence behavior rather than only mocking every database operation. Frontend tests Three focused frontend suites currently pass: authSessions API AccountSecurityPage MetaversePage Results: 3 suites passed 11 tests passed These tests verify:
  56. same-origin credentials;
  57. bearer-token fallback;
  58. encoded session identifiers;
  59. structured server errors;
  60. browser credential cleanup;
  61. active-session rendering;
  62. current-device labeling;
  63. remote revocation;
  64. removal of a revoked device from the interface;
  65. safe timestamp formatting;
  66. existing Neon Plaza mission-state behavior. Production build The React production bundle compiles successfully. The repository still reports three pre-existing ESLint warnings in legacy frontend files. They do not prevent the normal production build, but CI configured to treat every warning as an error can still fail. Remote integrity verification After pushing the changes, the local versions of the frontend and deployment files were compared with the contents stored on the GitHub branch. All nine checked files matched exactly. Current CI status and baseline failures The pull request remains a draft partly because repository-wide CI contains failures outside this authentication change. Previously observed baseline results included: 149 backend suites passed 34 backend suites failed

774 tests passed
44 tests failed
The sampled failures were primarily in existing areas such as:

  • Zorgax payment intents;
  • cultural modules;
  • Kefir workflows;
  • Marketplace source-contract tests;
  • realtime behavior;
  • observability source-string assertions. The existing security audit also reports vulnerabilities from the unchanged dependency lockfile. This authentication pull request does not modify package.json or package-lock.json. Another workflow checks the MYZ-164 Seller Free policy and currently fails on an existing Marketplace source-string assertion. The authentication changes do not modify that Marketplace implementation. These failures must not be hidden. At the same time, unrelated repository failures should not be “fixed” inside an authentication PR without proper scope and review. The correct approach is:
  • keep the authentication PR focused;
  • document the baseline accurately;
  • verify the changed components independently;
  • create separate work for unrelated failures;
  • merge only after the project’s review policy is satisfied. Security properties achieved The current implementation provides several concrete improvements:
  • a valid JWT can be revoked before expiration;
  • logout has real server-side meaning;
  • users can inspect active sessions;
  • users can remotely revoke another device;
  • current-session termination clears server and browser state;
  • session ownership is checked during revocation;
  • expiration is enforced during database queries;
  • raw IP addresses are not persisted;
  • cookie access is restricted with HttpOnly;
  • production cookies require HTTPS;
  • public errors avoid leaking JWT internals;
  • request IDs enable safer operational debugging;
  • older clients have a controlled migration path. What is not finished This is not the end of MYZ-71. Several security components are deliberately still missing.
  • Refresh-token rotation The current session token is still comparatively long-lived. The intended architecture should separate: Short-lived access token + Long-lived rotating refresh token A future access token should probably expire after approximately 5–15 minutes. The refresh token should:
  • be generated from cryptographically secure random bytes;
  • be stored only as a hash;
  • belong to a session and token family;
  • be rotated after every successful refresh;
  • invalidate the previous value;
  • detect reuse of an already consumed token;
  • revoke the complete token family after replay detection. A possible model is: { sessionId, familyId, refreshTokenHash, previousTokenHash, issuedAt, rotatedAt, expiresAt, consumedAt, revokedAt } Rotation must be atomic. Two simultaneous requests must not both successfully exchange the same refresh token. MongoDB transactions or a conditional findOneAndUpdate operation can enforce this: findOneAndUpdate( { sessionId, refreshTokenHash, consumedAt: null, revokedAt: null }, { $set: { consumedAt: now, replacedByHash: newTokenHash } } ); If the old token is presented again after it has been consumed, the system should assume possible theft and revoke the entire family.
  • Passkey-first authentication The roadmap calls for passkey-first authentication through WebAuthn. The expected API shape is: POST /api/auth/start POST /api/auth/verify The start endpoint should generate and persist a short-lived challenge. Verification must validate:
  • challenge equality;
  • expected origin;
  • expected RP ID;
  • allowed credential ID;
  • public-key signature;
  • authenticator data;
  • user-presence flag;
  • user-verification requirements;
  • signature counter behavior. Production validation must use the real MyZubster domain and must not accept development origins accidentally. Passkey enrollment also requires account-level endpoints for:
  • listing registered credentials;
  • naming a credential;
  • revoking a credential;
  • adding a second credential;
  • preventing accidental removal of the final recovery method.
  • Magic links Magic-link login can provide a fallback for users without passkeys. A secure implementation needs:
  • a random single-use challenge;
  • hashed challenge storage;
  • a short expiration;
  • email and IP-based rate limiting;
  • uniform responses to prevent account enumeration;
  • invalidation after first use;
  • invalidation when a newer link is generated;
  • redirect allowlisting;
  • protection from link-scanning software consuming the login prematurely. A magic link should not contain a reusable password-equivalent token that remains valid after verification.
  • Step-up authentication Some actions should require stronger, recent authentication even if the current session is valid. Examples include:
  • changing the primary email address;
  • adding or removing passkeys;
  • changing payout information;
  • linking a financial address;
  • exporting sensitive information;
  • changing privileged account roles;
  • deleting an account. The session model will need a value such as: stepUpVerifiedAt Sensitive middleware can then require: Date.now() - stepUpVerifiedAt < STEP_UP_WINDOW The frontend must distinguish between: Not authenticated Authenticated Authenticated but step-up required
  • CSRF hardening SameSite=Lax provides useful baseline protection, but it is not a complete CSRF strategy. Before moving to cookie-only authentication, state-changing requests should receive explicit CSRF protection. Options include:
  • synchronizer tokens;
  • signed double-submit cookies;
  • strict Origin and Referer validation;
  • custom request headers for same-origin frontend requests. OAuth callback state validation must remain separate from general application CSRF protection.
  • Remove browser-readable access tokens The current frontend still supports local storage because the application is migrating from the previous authentication architecture. That is not the final target. The desired final state is: Access/refresh credentials: HttpOnly secure cookies

Browser JavaScript:
no direct access to authentication secrets
After the legacy-token window closes, the project should:

  • stop returning long-lived tokens in JSON;
  • remove local-storage token writes;
  • remove bearer fallback from the web frontend;
  • enable strict server-session enforcement;
  • clear obsolete keys during migration.
  • Device-management improvements The first UI is functional, but it can be expanded with:
  • parsed browser and operating-system labels;
  • user-defined device names;
  • “revoke every other session”;
  • passkey management;
  • authentication history;
  • recent security events;
  • accessible confirmation dialogs;
  • complete internationalization;
  • session creation method, such as password, Google, GitHub or passkey;
  • coarse security-change detection without exposing raw IP data.
  • Production verification Several production checks remain essential:
  • confirm the secure cookie on the public HTTPS domain;
  • verify cookie path, domain and SameSite behavior;
  • test Google OAuth end to end;
  • observe and verify the GitHub OAuth callback in production;
  • test Facebook accounts with and without email;
  • verify remote revocation between two physical devices;
  • validate MongoDB TTL index creation;
  • investigate intermittent MongoDB Atlas ReplicaSetNoPrimary events;
  • test expiration and logout during database degradation;
  • confirm request-ID propagation through logs and proxies;
  • verify CORS and proxy behavior;
  • test the strict REQUIRE_SERVER_SESSION=true rollout. Why this matters for the metaverse Authentication infrastructure can appear separate from the visual metaverse experience, but it is foundational. A verified MyZubster identity connects: Account ↓ Persistent session ↓ Verified metaverse character ↓ Neon Plaza participation ↓ Missions and contributions ↓ Knowledge, marketplace and community activity Without revocable sessions, a stolen token could continue impersonating a verified character until expiration. With server-backed sessions, account identity and metaverse identity can share a controlled security lifecycle. This also prepares MyZubster for future features such as:
  • verified contributor profiles;
  • private rooms;
  • creator permissions;
  • moderation roles;
  • marketplace actions;
  • university pilots;
  • development requests;
  • community governance;
  • contribution-based rewards. Final status The completed work includes:
  • persistent server-side sessions;
  • JWT-to-session binding;
  • secure cookie support;
  • bearer migration compatibility;
  • active-device listing;
  • remote revocation;
  • real logout;
  • structured authentication errors;
  • privacy-preserving IP hashing;
  • social-login session issuance;
  • React account-security UI;
  • Neon Plaza integration;
  • protected Vercel routing;
  • focused backend and frontend tests. The remaining work includes:
  • refresh-token rotation;
  • replay detection;
  • passkeys;
  • magic links;
  • step-up authentication;
  • CSRF hardening;
  • removal of browser-readable tokens;
  • production OAuth verification;
  • MongoDB Atlas stability work;
  • resolution of repository-wide CI and dependency issues. This is an important step, but not a claim that authentication is “finished.” The objective is to build the security layer incrementally, test every transition, document the remaining risk, and avoid declaring the system complete before the evidence supports it. That is the same principle we want across MyZubster: Trust should come from verifiable behavior, not from unsupported claims.

Top comments (0)