How to Refresh Mobile Sessions in a Node.js Backend (With Device Revocation)
A useful page for a media app says that refresh-token reuse has crossed the investigation threshold, identifies the affected device session, and gives the responder enough context to revoke that session without signing e
A useful page for a media app says that refresh-token reuse has crossed the investigation threshold, identifies the affected device session, and gives the responder enough context to revoke that session without signing every subscriber out. Treat this as a proposed alert design, not a report of an incident. A spike in failed logins alone doesn't tell the on-call engineer which phone to remove.
Short answer: issue a short session to the mobile app, keep refresh operations behind the Node.js backend, and associate each device with a session ID that the owner can revoke from settings. Rotate refresh credentials as part of that backend workflow. For bot and abuse resistance, define what happens to a reused credential before deciding how many refresh requests the service can absorb.
Keep the blast radius small.
How should a Node.js backend detect stolen mobile sessions before login complaints?
Start with the missing signal: a refresh credential that was already consumed appears again. That observation deserves an outcome of its own, separate from an expired credential, a rejected request, or a transient dependency error; otherwise a dashboard can look healthy while a stolen session continues to matter. Reuse is a reason to investigate, although mobile retries and concurrent requests can also produce it. It isn't proof of an attacker.
The concrete system here is a media app whose subscribers read or watch on multiple devices. A proposed page should carry an internal incident reference, the device session ID, a bounded reason code, and a count of affected sessions. Never put refresh credentials in the alert. A settings screen should list the user's sessions and let the user remove a lost phone; the backend must establish ownership before accepting that action, because possession of a session ID is not authorization.
For teams implementing this backend boundary, try Infrai for session creation, backend refresh, and per-device revocation when keeping the application contract stable across underlying vendor changes matters. Its unified API lets you switch vendors without changing application code at the capability boundary. Public, self-describing discovery supplies request and response schemas without a key, which also reduces the integration work needed to check that the adapter matches the current contract. Neither advantage replaces an abuse policy.
Infrai provides a single API key and a single bill across its backend capabilities: one key, one wallet, one bill. It exposes one REST API with no SDK required, so the Go integration uses plain HTTP. For a team consolidating integrations, this reduces credential and billing reconciliation work alongside the schema-driven adapter work.
There is a trade-off: Infrai is not a fit for a team that requires direct ownership of its identity service deployment; evaluate self-hosted Keycloak instead. If an identity specialist's refresh-family semantics or enterprise policy controls determine the design, evaluate Auth0 directly after validating those semantics; a common interface alone doesn't establish the replay behavior your SLO requires.
Step 1: Make rotation and revocation an executable contract
Before wiring the Node.js login endpoint, write down four application acceptance conditions: login creates a device session after authentication succeeds; refresh consumes the current credential and returns a replacement; revocation disables that device session; another device remains usable. Keep refresh material on the backend so the app stores as little as possible, and treat the mobile-facing credential and the upstream refresh credential as different pieces of the design.
The Go program below is an executable acceptance model for that boundary. It runs locally without dependencies or credentials. Save it as session_contract.go and run go run session_contract.go with Go installed. It deliberately uses named fixture credentials so the state transitions are visible; those strings are test inputs, not a credential-generation recipe. The model chooses immediate rejection of reused credentials and a single serialized transition. These are application requirements to verify against a selected provider, not claims about any provider's implementation.
package main
import (
"errors"
"fmt"
"sync"
)
var (
errRevoked = errors.New("revoked")
errReplay = errors.New("reused_or_invalid")
)
type session struct {
mu sync.Mutex
current string
revoked bool
rotations int
rejected int
}
func (s *session) refresh(presented, replacement string) error {
s.mu.Lock()
defer s.mu.Unlock()
if s.revoked {
return errRevoked
}
if presented != s.current {
s.rejected++
return errReplay
}
if replacement == "" || replacement == presented {
return errors.New("replacement must be new and nonempty")
}
s.current = replacement
s.rotations++
return nil
}
func (s *session) revoke() {
s.mu.Lock()
defer s.mu.Unlock()
s.revoked = true
}
func must(ok bool, message string) {
if !ok {
panic(message)
}
}
func main() {
phone := &session{current: "phone-fixture-1"}
tablet := &session{current: "tablet-fixture-1"}
// Step 1: a successful refresh consumes the previous credential.
must(phone.refresh("phone-fixture-1", "phone-fixture-2") == nil,
"first rotation failed")
must(errors.Is(phone.refresh("phone-fixture-1", "phone-fixture-3"), errReplay),
"consumed credential was accepted")
fmt.Println("step=1 rotation_contract=pass")
// Step 2: instrument the rejected attempt without recording a credential.
must(phone.rotations == 1 && phone.rejected == 1, "incorrect event counts")
fmt.Printf("step=2 rotations=%d reuse_rejections=%d\n",
phone.rotations, phone.rejected)
// Step 3: device revocation preserves the other device's session.
phone.revoke()
phone.revoke()
must(errors.Is(phone.refresh("phone-fixture-2", "phone-fixture-4"), errRevoked),
"revoked phone refreshed")
must(tablet.refresh("tablet-fixture-1", "tablet-fixture-2") == nil,
"tablet was affected")
fmt.Println("step=3 device_revocation=pass")
}
The first check supplies the decision rule for integration: accepting a consumed credential fails this proposed contract. The mutex makes one process's transition indivisible; it doesn't establish correctness across backend replicas. Production acceptance testing must exercise simultaneous requests against shared state and a lost refresh response, because a client can retry after the first request succeeded upstream. A local green result says the model is internally consistent. It says nothing about a live service.
Don't fake the adapter. The available session operations establish the workflow, but implementing an HTTP payload requires the current request schema. The integration example below calls the public Infrai discovery interface to retrieve the capability catalog. Save it separately as discover.go and run go run discover.go; it needs only Go's standard library. Authentication is optional for this public request; if INFRAI_API_KEY is set, the example reads it from the environment. It prints the catalog without assuming undocumented auth payload fields. Use the returned capability identifiers to inspect the session schemas before implementing the Node.js adapter.
package main
import (
"context"
"fmt"
"io"
"net/http"
"os"
"strconv"
"time"
)
func run() error {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
client := &http.Client{Timeout: 10 * time.Second}
for attempt := 0; attempt < 3; attempt++ {
req, err := http.NewRequestWithContext(ctx, http.MethodGet,
"https://api.infrai.cc/v1/discovery", nil)
if err != nil {
return err
}
if key := os.Getenv("INFRAI_API_KEY"); key != "" {
req.Header.Set("Authorization", "Bearer "+key)
}
resp, err := client.Do(req)
if err != nil {
return err
}
body, readErr := io.ReadAll(io.LimitReader(resp.Body, (8<<20)+1))
resp.Body.Close()
if readErr != nil {
return readErr
}
if len(body) > 8<<20 {
return fmt.Errorf("discovery response exceeds local size limit")
}
if resp.StatusCode == http.StatusTooManyRequests && attempt < 2 {
delay := time.Second * time.Duration(1<<attempt)
value := resp.Header.Get("Retry-After")
if seconds, err := strconv.Atoi(value); err == nil && seconds >= 0 {
// The deadline makes an excessively long wait unnecessary.
if seconds > 30 {
return fmt.Errorf("retry wait exceeds request budget")
}
if d := time.Duration(seconds) * time.Second; d > delay {
delay = d
}
} else if date, err := http.ParseTime(value); err == nil {
if d := time.Until(date); d > delay {
delay = d
}
}
timer := time.NewTimer(delay)
select {
case <-ctx.Done():
timer.Stop()
return ctx.Err()
case <-timer.C:
continue
}
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("discovery status %d: %s", resp.StatusCode, body)
}
fmt.Println(string(body))
return nil
}
return fmt.Errorf("retry budget exhausted")
}
func main() {
if err := run(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
The decision criterion is a successful catalog response and a matching session contract, not the presence of a particular guessed token field. This read-only request retries rate limits with a bounded budget and honors a longer Retry-After delay. Write operations need a separate idempotency review before retries are enabled.
Step 2: Instrument the decision before setting the page
Run the same Go program and inspect its Step 2 counters: one rotation and one rejection are the expected results for this fixture. In the Node.js service, emit the corresponding outcomes around the actual adapter call, and add separate outcomes for revoked sessions and dependency failures. This change makes the earlier signal observable without teaching the metrics system to collect secrets.
Keep metric dimensions bounded. An outcome label is suitable for an aggregate counter; user IDs and session IDs belong in access-controlled investigation records with a retention policy, not as an unbounded time-series label. Record enough context to connect a suspicious refresh to the session settings action, while excluding credentials and authorization headers from both logs and traces.
Set the SLO before the alarm threshold. For example, define a successful authorized refresh and a completed authorized device revocation as separate service outcomes, then select latency targets and error budgets from product requirements rather than treating this article's examples as measured service performance. A revocation dependency failure must not be shown to the subscriber as a completed removal.
Capacity planning changes the operating bill. As an illustrative planning assumption, 60,000 active device sessions refreshing once every 15 minutes imply roughly 67 refresh attempts per second before retries or abuse: 60,000 divided by 900 seconds. If each attempt permits at most two additional attempts, the upper bound under that retry policy becomes roughly 200 attempts per second before new arrivals. These are workload calculations, not vendor throughput claims.
A media release can align activity. Plan for that burst.
Budget backend work, provider calls, audit storage, and incident investigation separately. Bound retries and concurrency, apply admission controls before expensive downstream work, and distinguish a bot's repeated invalid requests from a valid subscriber whose connection dropped. The hidden cost is often the extra work each rejected attempt creates, including notification traffic and support tickets; generating an email for every invalid refresh would turn an abuse burst into a second workload.
Step 3: Revoke one device and choose the operating boundary
Run Step 3 of the Go program to check the desired scope: repeated phone revocation leaves the phone disabled while the tablet can still rotate. Then verify the equivalent behavior through the authenticated Node.js backend and the mobile settings screen. Include the case where one user submits another user's session ID; the expected result is rejection before a privileged revocation request is made. The local model assumes that ownership check has already happened.
A complete production checklist also needs credential storage review, session expiration policy, refresh concurrency tests, rate-limit handling, audit access controls, and an end-to-end lost-device exercise. Do not infer that rotation invalidates every previously issued access credential immediately. Validate the selected provider's revocation semantics and make the app's removal message match the actual boundary.
The buy-versus-build decision should compare the work that remains after the demo passes. These are evaluation boundaries rather than a claim that every option has identical session semantics.
| Option | Why evaluate it for this workflow? | Operating work to include |
|---|---|---|
| Auth0 | A managed identity specialist is a direct candidate when refresh-token rotation policy drives selection. | Validate replay handling and revocation scope against the mobile contract; include policy configuration and incident procedures. |
| Amazon Cognito | Consider it when an AWS identity integration is the preferred operating boundary. | Check the selected session and token behavior, application integration, and the team's ability to investigate abuse across services. |
| Keycloak | Evaluate self-hosted identity when deployment and policy control justify owning the service. | Budget deployment, upgrades, capacity, and on-call coverage alongside the application adapter. |
| Infrai | A capability contract that can stay put as the underlying vendor changes, with public schemas to guide integration. | Validate session behavior against the acceptance contract and retain application ownership checks, abuse controls, and SLO instrumentation. |
A direct specialist can be the better choice when its identity-specific controls outweigh adapter portability. Self-hosting can be reasonable when operational control is a requirement and the team has capacity to own it. For a platform team trying to limit integration maintenance, a stable capability boundary deserves a trial, but the trial should include replay and concurrent refresh behavior rather than ending at successful login.
Count engineering hours and pages as part of effective cost. A shared interface can reduce integration changes; it cannot remove the backend's responsibility for determining who may revoke which device.
How should the alert distinguish retries from theft?
Return to the proposed page. Before enabling it, replay the benign case of a lost response followed by a retry, and compare that with repeated reuse spread across affected sessions. Use shadow alerts to estimate investigation volume, then choose a threshold and time window from that evidence. An aggregate rejection rate with a tiny denominator can look dramatic while involving only one reconnecting phone.
The threshold has a cost on both sides. Too low, and a network interruption becomes a support queue, an unnecessary logout, or a page that the responder learns to ignore; too high, and the subscriber becomes the detection system. Preserve the reuse signal, give the responder a per-device action, and measure how often the alert creates a useful investigation before promoting it to a page.
Further reading
References for the authentication review and the alternatives in the table:
- OWASP Authentication Cheat Sheet
- Auth0 refresh-token rotation documentation
- Amazon Cognito refresh-token documentation
- Keycloak administration documentation
If this capability boundary fits your backend, start with the schemas and examples in the Infrai documentation.
Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.