Random sign-outs: diagnose refresh token expiry, rotation races, clock skew
For developers debugging users who get logged out unpredictably despite successful initial sign-in. This runbook walks through the fastest checks for refresh token expiry, token rotation races, and clock skew, with concrete commands, log patterns, and remediation steps.
TL;DR — Random sign-outs usually come down to one of three things: refresh tokens expiring earlier than you think, multiple concurrent refresh requests invalidating each other during rotation, or server/client clock skew causing tokens to be treated as expired or not-yet-valid. Start by checking auth logs for repeated refresh failures and compare
iat/expagainst actual host time; the single most common fix is serializing refresh so only one in-flight refresh can rotate a token at a time. Reading time: ~6 min
The scenario
It is Tuesday afternoon, you shipped a harmless-looking frontend change, and support starts pasting screenshots that say users are being kicked back to the login screen every 10-30 minutes. Initial login works. API traffic looks normal except for a spike in 401 responses on the refresh endpoint, and the issue is worse for users with multiple tabs open or flaky mobile connections. You need to tell whether this is bad token lifetime config, a refresh rotation race, or time drift before you roll back the wrong thing.
Symptoms
- Users report: "I was actively using the app and suddenly got signed out."
- Browser devtools show refresh calls failing with
400,401, or403. - Common API responses:
HTTP/1.1 401 Unauthorized
content-type: application/json
{"error":"invalid_grant","error_description":"refresh token expired"}
HTTP/1.1 401 Unauthorized
content-type: application/json
{"error":"invalid_grant","error_description":"refresh token reuse detected"}
HTTP/1.1 400 Bad Request
content-type: application/json
{"error":"invalid_grant","error_description":"token is not active yet"}
- Backend/auth logs contain lines like:
refresh failed: invalid_grant: refresh token expired
refresh failed: invalid_grant: reuse detected for token family 8f2d...
jwt validation failed: token used before issued (iat in future by 97s)
jwt validation failed: exp claim is in the past by 42s
- Issue is more frequent when:
- the user has multiple tabs/windows open
- the SPA retries requests in parallel after a
401 - one app server or worker node was recently replaced or resumed from suspend
- Session cookie still exists, but refresh endpoint rejects it.
- Access tokens work until expiry, then refresh fails and the app logs out.
Likely causes
| Cause | How common | Quick check |
|---|---|---|
| Refresh token rotation race from concurrent refresh requests | Very common | `grep -Ei "reuse detected |
| Refresh token lifetime/config shorter than intended | Common | `grep -Ei "refresh token expired |
| Clock skew between app/auth/db hosts | Common | date -u && ssh auth-host 'date -u' && ssh app-host-2 'date -u' |
| Cookie expiry/attributes mismatch with refresh lifetime | Moderate | Browser DevTools → Application/Storage → Cookies → inspect Expires/Max-Age, SameSite, Secure |
| Old refresh token accepted by one node, rejected by another due to stale state/replication lag | Less common | `grep -Ei "token family |
Step-by-step diagnosis
View as text
[Browser Tab A] ---- 1. refresh ----> [Auth API] [Browser Tab B] ---- 2. refresh ----> [Auth API] [Auth API] ---- 3. rotate old->new ----> [Token Store] [Auth API] ---- 4. reject reused old ----> [Browser Tab B]
- Check whether failures cluster on refresh, not initial login.
grep -RniE "invalid_grant|refresh token|reuse detected|token is not active yet|exp claim" /var/log 2>/dev/null | tail -100
This is your problem if you see successful login events followed by refresh failures for the same user/session family. If logs show reuse detected or two refreshes within milliseconds, jump to Fixes → Refresh token rotation race from concurrent refresh requests. If you see expired, continue to step 2.
- Inspect token timestamps and compare to real UTC time.
TOKEN='eyJ...'
printf '%s' "$TOKEN" | awk -F. '{print $2}' | tr '_-' '/+' | base64 -d 2>/dev/null | jq '{iat,nbf,exp,jti,sub}'
date -u +%s
This is your problem if exp is already in the past earlier than expected, or iat/nbf is in the future relative to date -u +%s. If iat or nbf is ahead by more than a few seconds, jump to Fixes → Clock skew between app/auth/db hosts. If exp is simply too soon, jump to Fixes → Refresh token lifetime/config shorter than intended.
- Reproduce concurrent refresh from one session.
⚠️ This can invalidate the current refresh token family for the test user. Use a disposable test account, not your own admin session.
REFRESH='your-refresh-token'
URL='https://app.example.com/oauth/token'
for i in 1 2; do
curl -sS -o /tmp/resp.$i -w "req=$i code=%{http_code}\n" \
-X POST "$URL" \
-H 'content-type: application/x-www-form-urlencoded' \
--data "grant_type=refresh_token&refresh_token=$REFRESH" &
done
wait
for i in 1 2; do echo "--- $i ---"; cat /tmp/resp.$i; echo; done
This is your problem if one response succeeds and the other returns invalid_grant, reuse detected, or causes the newly issued token to fail on the next refresh. Jump to Fixes → Refresh token rotation race from concurrent refresh requests.
Typical output shape:
req=1 code=200
req=2 code=401
--- 1 ---
{"access_token":"eyJ...","refresh_token":"def502...","expires_in":900}
--- 2 ---
{"error":"invalid_grant","error_description":"refresh token reuse detected"}
- Compare time across every node that validates or rotates tokens.
date -u
ssh auth-1 'date -u && timedatectl show -p NTPSynchronized -p TimeUSec --value'
ssh app-1 'date -u && timedatectl show -p NTPSynchronized -p TimeUSec --value'
ssh app-2 'date -u && timedatectl show -p NTPSynchronized -p TimeUSec --value'
This is your problem if nodes differ by more than a few seconds, or NTPSynchronized=no. Jump to Fixes → Clock skew between app/auth/db hosts.
- Check whether the browser is dropping the refresh cookie before the server-side refresh lifetime.
curl -I https://app.example.com/
Then inspect the Set-Cookie header from a login/refresh response in browser devtools or via a captured response. This is your problem if cookie Max-Age/Expires is shorter than refresh token TTL, or SameSite/Secure prevents the cookie from being sent on the refresh request. Jump to Fixes → Cookie expiry/attributes mismatch with refresh lifetime.
Typical bad header shape:
HTTP/2 200
set-cookie: refresh_token=abc...; Max-Age=600; Path=/oauth/token; HttpOnly; Secure; SameSite=Lax
If your app relies on cross-site auth redirects or subdomain API calls, SameSite=Lax may be wrong.
- If you run multiple auth nodes, check shared state and replication lag.
grep -RniE "reuse detected|token family|revoked|replica|lag" /var/log 2>/dev/null | tail -100
This is your problem if one node issues a rotated token and another still validates against stale token-family state. Fix shared storage/consistency, then retest. Jump to Fixes → Old refresh token accepted by one node, rejected by another due to stale state/replication lag.
Fixes
Refresh token rotation race from concurrent refresh requests
Serialize refresh on the client so only one refresh request is in flight per session, and have all pending API calls await it.
Example pattern in JavaScript:
let refreshPromise = null;
async function refreshOnce() {
if (!refreshPromise) {
refreshPromise = fetch('/oauth/token', {
method: 'POST',
credentials: 'include',
headers: {'content-type': 'application/x-www-form-urlencoded'},
body: new URLSearchParams({grant_type: 'refresh_token'})
}).finally(() => {
refreshPromise = null;
});
}
return refreshPromise;
}
If refresh tokens are stored server-side, update rotation atomically. In SQL, treat old-token revocation and new-token insert as one transaction.
BEGIN;
UPDATE refresh_tokens
SET revoked_at = NOW()
WHERE token_id = $1 AND revoked_at IS NULL;
INSERT INTO refresh_tokens(token_id, family_id, parent_token_id, user_id, expires_at)
VALUES ($2, $3, $1, $4, NOW() + INTERVAL '30 days');
COMMIT;
Trade-off: strict reuse detection improves theft detection but punishes legitimate parallel refreshes unless the client is disciplined.
Verify it worked:
for i in 1 2; do curl -sS -o /dev/null -w "req=$i code=%{http_code}\n" -X POST https://app.example.com/oauth/token --data 'grant_type=refresh_token' & done; wait
Expected after client fix: only one request is sent, or the second waits; no logout on next API call.
Refresh token lifetime/config shorter than intended
Set refresh TTL explicitly in your auth service config instead of relying on defaults, then align cookie/session lifetime with it.
Example env/config:
export ACCESS_TOKEN_TTL=15m
export REFRESH_TOKEN_TTL=30d
export REFRESH_TOKEN_ROTATION=true
export CLOCK_SKEW_LEEWAY=60
systemctl restart auth.service
If your app stores refresh metadata in DB, confirm expiry values:
SELECT token_id, user_id, expires_at, revoked_at
FROM refresh_tokens
WHERE user_id = 123
ORDER BY created_at DESC
LIMIT 5;
Trade-off: longer refresh TTL reduces surprise logouts but increases the value of a stolen refresh token; pair it with rotation and reuse detection.
Verify it worked:
grep -Ei "REFRESH_TOKEN_TTL|ACCESS_TOKEN_TTL" /proc/$(pgrep -f auth.service | head -1)/environ 2>/dev/null | tr '\0' '\n'
And confirm newly issued tokens/cookies now expire when expected.
Clock skew between app/auth/db hosts
Install/enable time sync on every node that issues or validates tokens.
On systemd hosts:
timedatectl set-ntp true
timedatectl status
If chrony is used:
systemctl enable --now chronyd
chronyc tracking
chronyc sources -v
In token validation, allow small leeway for iat/nbf/exp.
{
"clockSkewSeconds": 60
}
Do not set leeway to several minutes unless you are masking a broken time source.
Verify it worked:
for h in auth-1 app-1 app-2; do echo "== $h =="; ssh $h 'date -u +%s'; done
Expected: values differ by only a second or two.
Cookie expiry/attributes mismatch with refresh lifetime
Align the cookie lifetime and attributes with how refresh is sent.
Example Set-Cookie target shape:
Set-Cookie: refresh_token=abc...; Max-Age=2592000; Path=/oauth/token; HttpOnly; Secure; SameSite=None
If nginx is rewriting cookie flags incorrectly, fix it:
proxy_cookie_path / "/; Secure; HttpOnly; SameSite=None";
If refresh happens on the same site only, SameSite=Lax may be acceptable; for cross-site or separate auth domain flows, use SameSite=None; Secure.
Verify it worked:
curl -skI https://app.example.com/oauth/token | grep -i set-cookie
Expected: Max-Age matches refresh TTL and flags match your request path/origin model.
Old refresh token accepted by one node, rejected by another due to stale state/replication lag
Store token-family state in strongly consistent shared storage, or route refresh requests to the same backend until rotation completes.
If using Postgres, validate replication lag before relying on a replica for token validation:
SELECT now() - pg_last_xact_replay_timestamp() AS replication_delay;
If lag is non-trivial, validate/rotate refresh tokens on the primary only, or move token-family state to a single-writer store.
Trade-off: primary-only validation is safer but adds load; eventual consistency on refresh-token family state causes exactly the "random logout" pattern you are chasing.
Verify it worked:
grep -RniE "reuse detected|replica|lag" /var/log 2>/dev/null | tail -50
Expected: reuse errors stop for normal single-session activity.
Prevention
- Add a refresh failure rate alert split by error string.
# Example log-based metric labels to extract:
# error_description=refresh token expired
# error_description=refresh token reuse detected
# error_description=token is not active yet
Alert on a sudden increase in invalid_grant after deploys.
- Add a CI test that fires parallel refresh requests against a test session and asserts no user-visible logout path.
./scripts/test-parallel-refresh.sh
Fail the build if one successful refresh causes the next legitimate request to lose session state.
- Pin token TTLs and clock skew leeway in config, not code defaults.
ACCESS_TOKEN_TTL=15m
REFRESH_TOKEN_TTL=30d
CLOCK_SKEW_LEEWAY=60
Check these into your deployment manifests.
- Monitor host time sync drift on every auth/app node.
timedatectl show -p NTPSynchronized -p TimeUSec
chronyc tracking
Alert if NTPSynchronized=no or drift exceeds 5s.
- Log token family identifiers and refresh request IDs so races are obvious.
{"event":"refresh_attempt","request_id":"...","family_id":"...","token_id":"...","result":"reuse_detected"}
Without family_id, rotation races look like random auth noise.
- If you use cookies for refresh, add an integration test that checks
Set-Cookieflags on login and refresh responses.
curl -skI https://app.example.com/login | grep -i set-cookie
curl -skI https://app.example.com/oauth/token | grep -i set-cookie
Assert Secure, correct SameSite, and Max-Age >= refresh TTL expectation.
This article was written by an AI system and published pending human review. Verify anything you intend to act on.
Have a project in mind?
Get an instant AI price estimate for it, or talk directly to our team.
One email a month on what we learn building with AI