Sep 20, 2025·8 min read

JWT auth problems in prototypes: expiry, refresh, clock skew

JWT auth problems often look random in prototypes. Learn fixes for expiry, refresh rotation, clock skew, and safe token storage patterns.

JWT auth problems in prototypes: expiry, refresh, clock skew

Why JWT tokens randomly stop working in prototypes

Most JWT auth problems in prototypes show up the same way: a login works, then users suddenly hit 401 errors, get kicked back to the sign-in screen, or see an app that only works after a refresh.

It feels random because JWTs are time-based, your prototype is usually held together by several moving parts, and small differences add up. One laptop’s clock is a few minutes off. One server is in a different timezone or has drift. A second browser tab keeps an older token and overwrites the newer one. Suddenly the same request succeeds for you and fails for someone else.

A quick mental model helps: almost every auth failure falls into one of three buckets.

  • Time: expiry (exp), issued-at (iat), clock skew, short-lived access tokens
  • Storage: token missing, overwritten, cleared, or stuck in the wrong place
  • Validation: wrong secret/public key, wrong audience/issuer, token revoked or rotated

Before you change code, capture the basics. It will save hours of guesswork.

  • The exact failing response (status + message) and which endpoint it happened on
  • A timestamp from the client and from the server logs for the same request
  • The device time and timezone (especially on mobile)
  • The token payload claims (exp, iat, iss, aud) and when it was minted

Concrete example: a founder tests on their Mac and everything works. A user on Windows keeps getting logged out every 10-15 minutes. The real issue is not “random logouts” - the user’s system time is 6 minutes behind, the access token expires fast, and the server rejects it with no tolerance. Add a second tab and you can also get one tab refreshing and the other tab overwriting stored tokens.

If your prototype was generated by tools like Lovable, Bolt, or Cursor, it’s common to see inconsistent token handling across pages. FixMyMess often finds a mix of short expiries, missing refresh logic, and unsafe storage all happening at once.

JWT basics you need for debugging (without the theory dump)

A JWT is just a signed note. Your server signs it so it can later verify the token was issued by you and was not changed. Most JWTs are not encrypted, so anyone who has the token can decode it and read what’s inside.

That leads to the first practical rule: treat JWTs like readable ID cards, not secret vaults. If a token leaks (logs, browser storage, screenshots, analytics tools), whatever is inside it leaks too.

The few claims that explain most failures

When tokens “randomly stop working,” it’s usually one of these fields, or how your app checks them:

  • exp (expires at): the moment the token must be rejected.
  • iat (issued at): when the token was created. Often used to debug timing issues.
  • nbf (not before): the token should be rejected until this time.
  • aud (audience): who the token is meant for (a specific API).
  • iss (issuer): which system issued the token.
  • sub (subject): the user or entity the token represents.

A common prototype mismatch: the frontend sends a token to API B that was minted for API A (wrong aud), or the backend is strict about iss in one environment but not another.

“It fails” can mean two opposite things

JWT auth problems often come from either missing checks or overly strict checks.

If checks are missing, tokens might work when they shouldn’t (security hole), and then “suddenly fail” after you add one validation rule.

If checks are too strict, users get kicked out for tiny differences, like a server clock being 60 seconds ahead (more on clock skew later), or a token that is valid but doesn’t match an exact aud/iss string.

What never belongs in a JWT

Don’t put secrets or sensitive data in a JWT payload: passwords, API keys, database credentials, reset codes, or personal data you wouldn’t want copied into a spreadsheet. Keep it minimal: an ID (sub), maybe a role or a few permissions, and the timing claims.

If you’re inheriting an AI-generated prototype, this is a frequent issue we see at FixMyMess: tokens that accidentally include internal keys or too much user data, and then get stored in unsafe places. Even if auth “works,” it’s one leak away from a real incident.

Expiry issues: exp and iat settings that cause surprise logouts

Most JWT auth problems in prototypes are not “random.” They are usually expiry math that looks fine in local testing, then breaks when real devices, real networks, and real time differences show up.

A few mistakes cause most surprise logouts:

  • exp is too short. Five minutes sounds safe, but it is brutal for prototypes with slow cold starts, backgrounded mobile apps, and spotty connections. Users come back after a short break and every call fails.
  • exp is missing. Some libraries will accept tokens with no expiry, some will not, and your own code might treat them as expired. This creates confusing, inconsistent behavior across environments.
  • iat is in the future. This happens when the server clock is off, you use the wrong timezone, or you generate tokens in one service and validate in another. Many validators reject tokens “not valid yet.”

A practical default pattern is: short-lived access token + long-lived refresh token. Keep the access token short enough to limit damage if stolen, but long enough to survive normal use. For many prototypes, a good starting point is 10 to 20 minutes for access, and days for refresh. Then you can tighten later.

Client behavior matters as much as token settings. A good rule is: handle a 401 once.

  1. If a request returns 401, call refresh.
  2. If refresh succeeds, retry the original request once.
  3. If the retry fails or refresh fails, log out and show a clear message.

This avoids infinite retry loops and “it keeps spinning” screens.

On the server, avoid vague 500 errors when a token is expired. Return a clear 401 for expired or invalid access tokens, and a clear 401 (or 403, if you prefer) when the refresh token is invalid or revoked. This makes it obvious whether you have an expiry problem or a real backend crash.

Example: a founder tests on a laptop and everything works. Users on mobile open the app after lunch, the access token expired, and the app keeps calling the API with the old token until it gives up. With the one-time 401 refresh-and-retry pattern, that same user just continues without noticing.

If you inherited an AI-generated auth flow where tokens “sometimes” fail, FixMyMess often finds a mix of too-short exp, inconsistent validation, and missing refresh logic in the first audit pass.

Clock skew: when correct tokens fail because time is off

Some JWT auth problems are not caused by bad code or “invalid tokens”. The token can be correct, signed, and unmodified, and still fail because the device or server clock is wrong.

Clock skew happens more often in prototypes than people expect: a phone with manual time set, a VM that drifts, a container running with a misconfigured time source, or two servers in the same app that disagree by a minute.

When time is off, you usually see failures around time-based claims:

  • nbf (not before): the server thinks the token is not active yet
  • exp (expires): the server thinks the token is already expired
  • iat (issued at): some libraries use it for extra checks and can reject “future” tokens

A common scenario: you sign a token on Server A, but the user’s request is validated on Server B whose clock is 45 seconds ahead. Suddenly, users get logged out “randomly”, or login works and then fails on the next page load.

Practical fix: add a small leeway when validating

Most JWT libraries let you allow a small tolerance when checking exp and nbf. A good starting point is 30 to 120 seconds. Keep it small: leeway is there to handle drift, not to extend sessions.

If you use leeway, treat it as a guardrail, not a band-aid. If you need 10 minutes of tolerance, you likely have a time sync or deployment issue.

Operational fix: make time consistent everywhere

Leeway reduces false failures, but you should still fix the root cause. Quick checks that catch most issues:

  • Ensure all servers and build agents sync with the same time source (NTP)
  • Avoid mixing hosts with correct time and containers with isolated or mis-set time
  • Verify mobile test devices are set to automatic time
  • In multi-server setups, confirm requests are not bouncing between nodes with different times

If you’re inheriting an AI-generated prototype (common with Lovable, Bolt, v0, Cursor, or Replit), clock skew bugs can be masked by “it works on my machine” testing. When FixMyMess audits auth failures, we often find a small tolerance plus proper time sync removes the flaky logouts without changing the whole auth design.

Refresh tokens: the missing piece in most prototype auth flows

Security Hardening for JWT Apps
Patch common issues like SQL injection risks and unsafe auth shortcuts in prototypes.

Most JWT auth problems in prototypes happen because the app tries to use one token for everything: a short-lived access token that also has to keep the user signed in for days. That tension is why sessions feel random. A refresh token exists to keep a session alive without making the access token long-lived (which is risky if it leaks).

Access tokens should be boring: short expiry, sent often, easy to replace. Refresh tokens should be rare: used only to get a new access token, then kept out of places where JavaScript or logs can easily grab them.

A common prototype mistake is storing the refresh token the same way as the access token (for example, in localStorage) and treating it like a normal API credential. When that token leaks, an attacker can mint new access tokens until you notice. Another mistake is not having a refresh token at all, so the app “solves” logouts by setting access token expiry to something huge. That usually turns into security debt later.

Before you build the flow, decide what “session” should mean for your product:

  • How long should a user stay signed in without opening the app?
  • Should closing the browser log them out, or not?
  • Do you want one session per device, or signing in on one device should kick out another?
  • What happens after a password change: log out everywhere, or only new sessions?

Real usage also adds edge cases that prototypes rarely handle. Users open multiple tabs, networks drop mid-request, and two requests can try to refresh at the same time. If you don’t control that, you get loops (refresh, fail, retry) or sudden 401s that look like “JWT auth problems” even when your tokens are fine.

If you inherited an AI-generated prototype (Lovable, Bolt, v0, Cursor, Replit), this refresh layer is often missing or half-wired. Fixing it usually removes the “it worked yesterday” login bugs first.

Step by step: a refresh flow that works with rotation

Most JWT auth problems show up when access tokens expire and the app has no reliable way to recover. A rotation-based refresh flow fixes that by making expiry normal, not scary.

The flow (login to refresh to retry)

Start by minting two tokens at login: a short-lived access token (minutes) and a long-lived refresh token (days or weeks). The access token is what your API checks on every request. The refresh token is only used to get a new access token.

A simple, reliable sequence looks like this:

  • Login returns access token + refresh token
  • Client calls APIs with access token until it fails with 401
  • Client calls the refresh endpoint with the refresh token
  • Server returns a new access token and a new refresh token
  • Client retries the original API call once

Rotation is the key detail: every refresh produces a brand-new refresh token, and the old one becomes invalid. That way, if a refresh token leaks, it stops working as soon as the real user refreshes.

Rotation rules you should implement server-side

To make refresh token rotation actually safe (and not randomly break users), keep these rules tight:

  • Store refresh tokens server-side (hashed), with user id, expiry, and a unique token id.
  • On refresh, accept only the current token id, then immediately mark it as used/revoked and issue a new token id.
  • If an old token is presented again, treat it as suspicious and revoke the whole session (or require re-login).

Concurrency is where prototypes often get flaky. Two tabs, a double click, or a mobile app retry can trigger two refreshes at once. A small grace strategy prevents surprise logouts: allow the previously valid refresh token to be used one extra time for a short window (for example 10-30 seconds) if it was just rotated, but only if you can detect it belongs to the same session chain.

If you’re fighting JWT auth problems that feel random, it’s usually because rotation, storage, or concurrency is half-implemented. Teams often bring FixMyMess a prototype from Lovable/Bolt/v0/Cursor/Replit where refresh looks “done” but breaks under real user behavior, and we harden it into a production-safe flow quickly.

Safe token storage patterns (web and mobile)

Make JWT Validation Consistent
Align iss, aud, keys, and leeway across dev, preview, and prod.

If you are seeing JWT auth problems that only show up outside your own browser, storage is often the reason. A prototype can feel fine in testing, then start “randomly” logging people out once you add real pages, third party scripts, or different devices.

Web: treat the refresh token like a password

For browser apps, the safest default is to store the refresh token in an httpOnly, Secure cookie. httpOnly keeps JavaScript from reading it (helps a lot if you ever get an XSS bug). Secure ensures it only travels over HTTPS.

Cookie flags are not set-and-forget. Make them a deliberate choice:

  • httpOnly + Secure: good baseline for refresh tokens.
  • SameSite=Lax: usually works for normal navigation and reduces CSRF risk.
  • SameSite=None: needed for cross-site setups (different domains), but requires Secure and increases CSRF exposure.
  • CSRF protection: if the refresh endpoint is cookie-based, add CSRF defenses (for example, a CSRF token header or double-submit token).

Avoid storing long-lived tokens in localStorage or sessionStorage. It is convenient, but if any script on the page can run (XSS, compromised dependency, injected browser extension), it can read tokens and send them out.

Access tokens are different: keep them short-lived and store them in memory when possible. If a page refresh loses them, that is fine because the refresh cookie can mint a new one.

Mobile: use secure storage, keep access tokens short

On iOS and Android, store refresh tokens in the platform’s secure storage (Keychain on iOS, Keystore on Android). Do not put refresh tokens in plain app storage or logs.

A practical pattern:

  • Refresh token: secure storage, long-lived, rotated.
  • Access token: memory only, short expiry, replace often.
  • On app background/close: drop the access token and fetch a new one on resume.

One more quiet source of “random” failures: tokens leaking into places you do not expect. Never put access tokens in URLs (query params), and scrub them from logs, analytics events, crash reports, and error popups. For example, if your app logs the full request when an API call fails, it may accidentally capture the Authorization header.

If you inherited an AI-generated auth flow that mixes cookies, localStorage, and long-lived access tokens, FixMyMess can audit it quickly and point out the exact leak points and unsafe storage choices before you ship.

Common prototype traps that make auth flaky

JWT bugs in prototypes often feel random because the system is half strict and half loose. It “works on your machine,” then breaks after a redeploy, a second service is added, or a user opens another tab.

Trap 1: Skipping issuer (iss) and audience (aud) checks

Many prototypes only validate the signature and expiration. That can be fine for a single backend, but it becomes fragile the moment you add a second API, a background worker, or a separate admin service.

If you don’t validate iss and aud, you can end up accepting tokens meant for another service, then later “tighten security” and suddenly real users get 401s because existing tokens don’t match the new rules.

A simple way to avoid surprises is to decide early:

  • One issuer string for your auth service
  • One audience per API (or one shared audience if you truly have one API)
  • Consistent settings across dev, staging, and prod

Trap 2: JWT secrets changing across environments or redeploys

Prototypes often generate secrets on boot, use different .env files per machine, or rotate keys by accident when deploying. The result looks like “tokens randomly stop working,” but the real issue is that the server can no longer verify tokens it issued earlier.

If you need to redeploy often, treat signing keys like a database password: keep them stable and managed. If you plan to rotate keys, do it deliberately (for example, by supporting a current key and a previous key for a short overlap).

Trap 3: Trusting client-side decoding instead of server verification

Decoding a JWT in the browser (or mobile app) is not the same as verifying it. A prototype might read the payload, assume the user is logged in, and skip a real server check until later.

That creates confusing states: the UI says “logged in,” but the API rejects requests. Make the server the source of truth and have the client treat “API says 401” as the real signal to refresh or re-auth.

Trap 4: Caching auth state incorrectly (especially across tabs)

A common bug: one tab refreshes tokens, another tab keeps using an old access token, and your app flips between working and failing. Another version: you cache a “current user” in memory and never update it after a refresh.

If your app runs in multiple tabs, decide how auth state updates propagate. At minimum, handle “token updated” and “logged out” events cleanly so you do not keep sending stale tokens.

Trap 5: Shipping debug auth shortcuts

Prototype code sometimes accepts unsigned tokens, uses the none algorithm, or bypasses verification for testing. If any of that reaches production, you get both security risk and weird failures when different parts of the system disagree on what’s valid.

If you inherited an AI-generated prototype and auth feels flaky, FixMyMess can audit the codebase quickly (including token verification, rotation logic, and storage) and point out exactly which of these traps you’re hitting before users do.

Example: it works for you, but users keep getting logged out

Find Exposed Secrets Now
We scan for leaked keys and unsafe JWT payloads before they ship.

A common story: you test the app all day on your laptop and auth feels fine. You ship a preview to real users, and suddenly you get messages like, “I get kicked out every 10 minutes” or “Login works once, then it randomly stops.”

Here’s a realistic scenario. A founder builds a prototype in an AI tool, runs it locally, and signs in through the same browser tab. In production, users open the app on their phone, switch networks, background the tab, then come back later. That is exactly where JWT auth problems show up: short expiries, clock differences, and refresh logic that only works in the happy path.

How to reproduce it (without guessing)

Start by turning “random logouts” into a timeline. Ask one affected user for the time they logged in and the time they got logged out (their timezone matters). Then collect one failing request on the server (or in logs) and capture:

  • Server time when the request was rejected (401)
  • The token’s exp and iat values (decoded server-side)
  • Whether a refresh attempt happened, and if it failed
  • Whether the user had multiple devices or multiple tabs open

If you can, compare the server clock to a trusted source and to your local machine. A few minutes of drift is enough to break strict validation.

The most likely causes

In prototypes, these issues show up again and again:

  • exp is too short (like 5-15 minutes), and there is no reliable refresh flow, so users get surprise logouts.
  • Clock skew JWT failures: the backend checks exp and iat with zero leeway, and one machine’s time is slightly off.
  • JWT refresh token rotation bugs: you rotate refresh tokens, but you do not handle “token reuse” correctly, or you overwrite the stored refresh token in a race between two requests.

A practical fix path

Fixes are usually straightforward when you tackle them in order.

First, add a small leeway when validating time claims (often 30-120 seconds). That alone can stop false expiries caused by clock skew.

Next, make refresh reliable. When an access token expires, do a single refresh retry (once) and then replay the original request. If multiple requests hit expiry at the same time, ensure only one refresh happens and the others wait for it.

Finally, tighten storage. Use secure token storage cookies for web when possible (httpOnly, secure, sameSite set correctly), and avoid keeping long-lived refresh tokens in localStorage. On mobile, use the platform’s secure storage.

If your prototype was generated quickly and auth feels flaky, FixMyMess can run a free code audit to pinpoint where the refresh logic, rotation, or storage patterns are breaking in real usage, then patch it into something production-ready.

Quick checklist and next steps

When JWT auth problems feel “random,” they usually aren’t. A token fails for a small set of reasons: the numbers in it are wrong, the server’s clock is off, the refresh flow is incomplete, or your app stores tokens in a fragile way.

Start with quick proof, not guesses. Copy a failing token, decode it, and write down exp, iat, iss, aud, and the user id/subject. Then compare that to what your API is actually validating in this environment.

Here’s a short checklist that catches most prototype issues:

  • Decode a failing token and confirm exp is in the future and iat is not “in the future” compared to the API server time.
  • Verify API time: check the server clock, container time, and hosting time sync. Even a few minutes of drift can cause sudden failures.
  • Confirm signing config: make sure the secret/private key is correct for this environment (dev vs preview vs prod), and that you are not mixing keys between services.
  • Validate audience/issuer: aud and iss must match what the API expects in every environment (especially preview deployments).
  • Test refresh behavior: refresh endpoint returns a new access token reliably, and refresh token rotation does not accidentally invalidate users who still have an active session.

After that, do one end-to-end test like a real user: sign in, wait for the access token to expire, then make an API call and watch the app refresh and retry. If it fails, look for these patterns: refresh token not sent (cookie flags wrong), refresh token overwritten during rotation, or the client never retries the original request.

Storage is your last quick check: avoid putting refresh tokens in localStorage. For web apps, an HTTP-only cookie is usually the safer default. For mobile, use the platform’s secure storage.

If your AI-generated prototype (Lovable, Bolt, v0, Cursor, Replit) has flaky auth and you need it production-ready fast, FixMyMess can run a free code audit to pinpoint what’s breaking and then repair or rebuild the flow safely.