1. A Familiar Incident Pattern
A fintech app rolls out a new mobile release. Authentication is "modern": OAuth, JWTs, short login flows, smooth UX.
A few weeks later, support tickets appear:
- Sessions users didn't initiate
- Payments they didn't authorize
- Profile details changed without consent
The root cause isn't a zero-day exploit or broken cryptography.
It's architectural misuse of JWTs.
This pattern repeats across consumer apps, enterprise mobility platforms, and SaaS products β even in mature teams. The problem is not ignorance of JWTs, but over-trust in them.
JWTs are often treated as a security primitive. They are not.
JWTs are a data format with cryptographic properties β safe only when embedded in a disciplined system design.
When that discipline slips, account takeover (ATO) becomes not just possible, but likely.
2. Mistake #1: Treating JWTs as Session State
What the mistake is
Using long-lived JWTs as a replacement for server-side sessions:
- No server session tracking
- No revocation capability
- Token validity equals access validity
Once issued, the token is the session.
Why teams do this
- Stateless APIs scale well
- JWT tutorials emphasize "no server storage"
- Backend complexity appears lower (initially)
How it gets exploited
If an attacker extracts a valid JWT (via malware, reverse-engineering, MITM on compromised devices, logs, or backups), they gain full session equivalence until expiry.
There is no server authority to say:
"This token should no longer be trusted."
Real-world impact
- Silent account takeover
- Persistent unauthorized access
- No surgical incident response β only mass logout
Why this is architecturally flawed
Authentication and session control are stateful security problems. Removing state removes your ability to react.
JWTs can represent sessions β but they cannot be the session.
3. Mistake #2: Insecure Token Storage on Mobile Devices
What the mistake is
Storing access or refresh tokens in:
- Plain
SharedPreferences/UserDefaults - Unencrypted local databases
- App memory without lifecycle controls
- Logs, crash reports, or analytics events
Why teams do this
- Secure storage APIs feel "slow" or "complex"
- Debug conveniences leak into production
- Over-trust in OS sandboxing
How attackers exploit it
Mobile apps are not black boxes:
- Rooted / jailbroken devices bypass sandbox assumptions
- Backup extraction exposes app storage
- Reverse-engineering reveals storage paths
- Malware reads app files directly
Android example (what not to do)
// β Insecure: readable on rooted devices, backups, or via malware
val prefs = getSharedPreferences("auth", MODE_PRIVATE)
prefs.edit().putString("access_token", token).apply()Correct modern pattern (Android)
- Store long-lived secrets (refresh tokens) using Android Keystoreβbacked keys
- Encrypt token material yourself using AES-GCM
- Avoid deprecated wrapper libraries
- Prefer hardware-backed keys when available
Note: Jetpack
EncryptedSharedPreferences/security-cryptois deprecated and should no longer be treated as a forward-looking solution.
Real-world impact
- Token theft β direct account takeover
- No backend exploit required
- Even "secure" backend systems are bypassed
4. Mistake #3: Overloading JWTs with Authority
What the mistake is
Embedding sensitive authorization data inside JWTs:
- Roles
- Account tier
- Feature flags
- Trust levels
β¦and trusting those claims blindly on the backend.
Why teams do this
- Fewer database calls
- Lower latency
- Clean, stateless design
How it gets exploited
JWTs are only as trustworthy as:
- Signing key protection
- Validation rigor
- Lifecycle discipline
Common failure modes:
- Key leakage
- Algorithm confusion
- Weak validation rules
- Overlong token lifetimes
When compromised, attackers gain privilege escalation, not just access.
Insecure backend logic
if jwt.isValid():
role = jwt.claim("role")
if role == "admin":
allowSensitiveAction()Secure alternative
if jwt.isValid():
userId = jwt.subject
session = sessionStore.get(userId)
permissions = permissionService.resolve(session)
enforce(permissions)Architectural principle
JWTs should reliably answer one question only:
"Who is making this request right now?"
Authorization is a server decision, not a client-supplied claim.
(If you do use scopes or claims, they must be minted by a hardened authorization server, tightly audience-bound, short-lived, and revocable.)
5. Mistake #4: No Real Token Revocation or Rotation Strategy
What the mistake is
- Short-lived access tokens with no refresh rotation
- Refresh tokens without server-side tracking
- "Logout" that only deletes local tokens
Why teams do this
- JWT revocation feels "hard"
- Tutorials skip lifecycle realities
- Logout is treated as a client concern
How attackers exploit it
If a refresh token is stolen:
- New access tokens can be minted indefinitely
- Password reset does not remove attacker access
- Compromise persists silently
Common broken assumption
"We use short-lived access tokens, so we're safe."
Without refresh token revocation, you are not.
Production-grade pattern
- Store refresh tokens server-side (hashed)
Bind refresh tokens to:
- Device
- Client
- Session identifier
- Rotate refresh tokens on every use (with replay detection)
Invalidate tokens on:
- Password change
- Device removal
- Risk events
Optional hardening:
- Sender-constrained tokens (DPoP / mTLS)
Trade-off
More backend state and logic β but real control and incident response.
6. Mistake #5: Weak JWT Validation on the Backend
What the mistake is
Relying on libraries with default or partial validation:
- Skipping
aud(audience) - Ignoring
iss(issuer) - Accepting multiple algorithms
- Loose clock skew handling
- Accepting wrong token types
Why teams do this
- "The library handles it"
- Copy-pasted examples
- Incomplete threat modeling
How it gets exploited
Attackers craft tokens that:
- Are validly signed but meant for another service
- Use weaker or unexpected algorithms
- Exploit relaxed validation paths
Minimum validation checklist
A production backend must enforce:
- Explicit signature verification (fixed algorithm)
- Issuer (
iss) match - Audience (
aud) match - Expiry (
exp) - Not-before (
nbf) - Clock skew bounds
- Token use / type (access vs refresh vs ID token)
JWT validation is not authentication unless all of these hold.
7. Mobile-Specific Risks Teams Underestimate
App lifecycle exposure
- Tokens persist across backgrounding
- Screen recording or overlays capture sensitive flows
- Debug builds accidentally shipped
Reverse-engineering reality
- Mobile binaries are inspectable
- Hardcoded assumptions are discoverable
- API behavior is reproducible
The mobile app must be treated as a hostile environment.
Trust boundaries end at the API β not the UI.
8. Correct Architectural Patterns (Defense-in-Depth)
What works in production
- Short-lived access tokens
- Stateful refresh tokens
- Server-side session awareness
- Device-bound secure storage
- Explicit backend validation
- Centralized revocation capability
Design principle
JWTs should answer only one question:
"Who is making this request, right now?"
Everything else β authorization, risk, trust β is a server decision.
UX vs Security trade-off
Yes, this adds complexity:
- More backend storage
- More flows
- More edge cases
But the cost of not doing this is invisible β until it isn't.
9. Lessons for Architects and Tech Leads
JWT-related account takeovers are not tooling failures. They are boundary failures.
They reveal:
- Poor separation of identity vs authority
- Confused trust models between mobile and backend
- Over-optimization for statelessness
- Underinvestment in incident response capability
Security is not a feature you add. It is a system property you preserve.
JWTs are powerful β but only when they are contained, validated, and revocable within a consciously designed architecture.
Final Thought
If your system cannot answer:
"Can we immediately cut off this user, on this device, right now?"
Then JWTs are not your problem.
Your architecture is.