Treat a session inventory as an abuse-control surface, not an account-history page: store server-side session records, show only the sessions owned by the authenticated customer, and make revocation an atomic state change that every protected request enforces. For an e-commerce app adding phone one-time-code login, the decisive rule is stricter: a successful code proves control of a phone number at that moment; it must not allow a bot to mint an unlimited fleet of durable sessions.
Short answer: cap new sessions per account and per risk boundary, rotate the session identifier after OTP verification, hash identifiers at rest, require recent reauthentication for suspicious revocation, and invalidate a revoked record before returning success. The account page should receive a stable opaque ID, approximate device data, creation and last-seen times, and a current flag. It should never receive bearer tokens or raw cookie values.
How should Node.js list user sessions and revoke one safely?
The dangerous alert is not “OTP failures increased.” That signal can be loud while the actual compromise stays quiet. The page-worthy condition connects challenges, successful verifications, and resulting sessions: one phone number acquiring many sessions, one network boundary completing codes for many accounts, or a sharp divergence between checkout traffic and login-session creation. A dashboard of total logins can hide all three, which is why I would page on a broken relationship between verification and session creation rather than on either total by itself.
The gap matters.
Phone OTP also changes the threat model. OWASP advises treating sensitive account changes as high-risk actions and using reauthentication after suspicious activity; it also calls for generic authentication responses so an attacker cannot use response differences to enumerate accounts. Those constraints apply before the inventory is rendered: an account lookup, code request, and verification response must not reveal whether a phone number exists, while rate limits need dimensions broader than the phone number alone.
Use several independent controls because each one has a blind spot:
- throttle code requests and verification attempts by account, destination, network boundary, and device signal;
- limit active sessions per account, with a documented rule for rejecting or retiring the oldest session;
- issue a fresh session identifier only after verification, then rotate it again after any privilege change;
- alert on the ratio of successful OTP checks to sessions created, not merely raw request volume.
No single threshold is universally correct. A household sharing an address, a carrier-grade NAT, and an automated credential attack can look alike from one field. Start with conservative limits, record which dimension triggered, and review false positives before moving from challenge to denial. The trade-off is deliberate: a little more friction at login is preferable to discovering at 3 a.m. that the “successful authentication” graph was measuring an attacker’s throughput.
Make revocation a state transition, not a cookie trick
A browser can delete only its own cookie. Account settings must revoke a server-side authorization state, because the target may be a lost phone on another continent. Keep an opaque random token in a Secure, HttpOnly cookie with an appropriate SameSite policy; store only its cryptographic hash in the session table. RFC 6265 defines the cookie model, while MDN documents the security purpose and limitations of these attributes.
The useful record is small: internal session ID, user ID, token hash, creation time, last-seen time, expiry, revoked time, and coarse display metadata captured at creation. User-agent text is descriptive, not identity proof. IP addresses change and can be shared, so display them carefully and do not make them the sole authorization check.
Here is the critical service boundary in Go. A Node.js HTTP layer can call the same database transaction and expose the same JSON contract; the security property belongs in the persistence operation, where a second request cannot slip between an ownership check and an update.
package sessions
import (
"context"
"database/sql"
"errors"
"time"
)
var ErrNotFound = errors.New("session not found")
type Store struct{ DB *sql.DB }
type Session struct {
ID string `json:"id"`
Device string `json:"device"`
CreatedAt time.Time `json:"createdAt"`
LastSeen time.Time `json:"lastSeenAt"`
ExpiresAt time.Time `json:"expiresAt"`
RevokedAt *time.Time `json:"-"`
Current bool `json:"current"`
}
func (s *Store) Revoke(ctx context.Context, userID, sessionID string) error {
result, err := s.DB.ExecContext(ctx, `
UPDATE login_sessions
SET revoked_at = CURRENT_TIMESTAMP
WHERE id = $1 AND user_id = $2 AND revoked_at IS NULL`,
sessionID, userID,
)
if err != nil {
return err
}
rows, err := result.RowsAffected()
if err != nil {
return err
}
if rows == 0 {
return ErrNotFound
}
return nil
}
The user_id predicate is the ownership boundary. Do not load by session ID and then compare in application code; the atomic predicate is easier to audit and closes the obvious cross-account race. Repeating the operation should produce the same externally safe result. An authenticated caller does not need to learn whether an opaque ID belongs to somebody else.
Every protected request must then check that the matching record is unrevoked and unexpired. Cache that decision only if the tolerated revocation delay is explicit. A five-minute cache means “sign out this device” can remain ineffective for five minutes, which is an incident-response decision disguised as a performance setting.
Return enough context, but no new credential
The list operation should sort active records predictably and mark the current one by comparing its authenticated session ID on the server. Avoid accepting current=true from the client. Also avoid returning precise location claims derived from an IP address; “Chrome on Windows” and an approximate last-used time are useful hints, while false precision can persuade a customer to revoke the wrong device.
A compact response can look like this:
{
"sessions": [
{
"id": "ses_7fb2d6",
"device": "Chrome on Windows",
"createdAt": "2026-09-18T08:12:41Z",
"lastSeenAt": "2026-10-03T01:44:09Z",
"expiresAt": "2026-10-10T08:12:41Z",
"current": true
}
]
}
That ID is a database identifier, not the cookie secret.
Keep that distinction absolute.
For the current session, decide the user experience before implementation. Revoking it can commit the state, clear the cookie, and redirect to login. Revoking another session should preserve the caller’s session. “Sign out everywhere else” deserves a separate atomic operation scoped to user_id and excluding the current ID; looping over rows from the browser creates partial completion and noisy audit trails.
Verification that survives a 3 a.m. rollback
Test authorization before presentation. Create two users, give each two sessions, and prove that user A cannot list or revoke any record owned by user B, including by guessing a syntactically valid ID. Then cover a repeated revoke, an expired record, concurrent revoke requests, current-session revocation, and a request already in flight when revocation commits. The expected result for that last case must be written down; transaction isolation and middleware timing determine it.
Abuse tests need their own lane. Simulate many code requests to one destination, many destinations behind one network boundary, successful verification followed by rapid session creation, and retries caused by client timeouts. Confirm that a retry cannot create another session accidentally. Confirm, too, that logs contain an outcome and correlation ID but never the OTP, raw session token, or full cookie header.
The operational invariant is simple: after the revocation transaction commits, no newly authorized request may succeed with that session. Measure that invariant directly with a counter for rejected revoked sessions and a latency distribution from revoke commit to first rejection. Page on sustained invariant violations, not on ordinary customer cleanup.
Roll out the read path first, then revocation, then session caps and abuse responses. During rollback, disabling the account-page control is acceptable; restoring acceptance of records already marked revoked is not. Preserve revocation state, keep the request-time check active, and roll back only the UI or new policy logic. Security state should move in one direction.
The decision rule
Ship the inventory only when ownership filtering, atomic revocation, request-time enforcement, audit events, and abuse limits are tested together. The table can be plain. The controls cannot.
For an OTP-based storefront, review the session-creation alert first: which page fires, which dimension triggered it, and whether the responder can stop new sessions without breaking checkout for every customer. If those answers depend on reading several dashboards and guessing at cache behavior, the feature is not ready for the pager.
References
- https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html
- https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html
- https://www.rfc-editor.org/rfc/rfc6265
- https://developer.mozilla.org/en-US/docs/Web/HTTP/Cookies
- https://pages.nist.gov/800-63-4/sp800-63b.html
Top comments (0)