In Episode 6, we tackle the authentication problem that web advice doesn't solve: how to keep users logged in for months while minimizing the damage window if tokens are stolen.
⚠️The Problem with JWTs on Mobile
The standard advice in web circles is: “Just use JWTs and set a short expiry.” But mobile introduces a unique problem: The Persistence Problem.
Your user expects to log in once, maybe every few months. A secure messaging app or enterprise productivity tool cannot log the user out every hour. They demand long-lived sessions, but long-lived tokens are inherently dangerous.
Why? Revocation.
Imagine you have a 30-day JWT for an enterprise app accessing internal documents. If the user's phone is stolen, or malware extracts that token, the attacker now has access to your backend for 30 days.
Since JWTs are designed to be stateless, the server typically cannot revoke that specific token until it expires naturally. This creates an unacceptably large exposure window.
🎯The Solution: The Dual-Token Model
The industry standard for secure, persistent mobile sessions uses two distinct tokens:
1. The Access Token
Attached to every API request. Designed to be short-lived (5-15 minutes).
If stolen via network interception, the exposure window is tiny.
2. The Refresh Token
Represents the user's long-term session. Its only job is to be exchanged for a new Access Token when the old one expires.
This is your crown jewel—it must be protected at all costs.
Think of it like a hotel key card system:
- •The Access Token is the temporary key card to your room. It expires daily.
- •The Refresh Token is your ID at the front desk. It allows you to get a new key card without re-booking the entire reservation.
🔒Storage: Keychain Only
Because the Refresh Token allows a user to generate new Access Tokens, it is your crown jewel. If an attacker steals it, they can potentially impersonate the user indefinitely.
⚠️ UserDefaults is unacceptable
It is not encrypted and is easily dumped from backups. Never store tokens in UserDefaults.
The Keychain is hardware-backed. Use kSecAttrAccessibleAfterFirstUnlock for the strongest protection that still allows background refreshes.
🔄The Secret Weapon: Token Rotation
A 30-day static token is dangerous. If it's stolen on Day 1, the attacker has 29 days of access.
Rotation makes the Refresh Token single-use.
When the app uses RefreshToken_A to get a new Access Token, the server:
- 1.Verifies
RefreshToken_A - 2.Issues the new Access Token
- 3.Issues a brand new
RefreshToken_Band immediately invalidatesRefreshToken_A
The chain of trust can last for a year, but each individual link is used only once.
🔒 Theft Detection
If the server sees an attempt to use an already-used token, it implies theft. The server can then invalidate the entire session family, stopping the attacker in their tracks.
💻Implementation References
Official Documentation
📚 Apple Developer Documentation
- Using the Keychain to Manage User Secrets → Complete sample project for secure token storage
- Adding a Password to the Keychain → Official SecItemAdd sample code
- kSecAttrAccessibleAfterFirstUnlock → Recommended accessibility level for refresh tokens
🔐 OAuth 2.0 / Token Rotation Specs
- RFC 6749: OAuth 2.0 Authorization Framework → The foundational spec for access/refresh token patterns
- OAuth 2.0 Security Best Current Practice → IETF guidance on token rotation and theft detection
Key Implementation Patterns
Keychain Token Storage
Store refresh tokens using the Keychain Services API with these key attributes:
kSecClass: kSecClassGenericPasswordkSecAttrService— unique identifier for your tokenkSecAttrAccessible: kSecAttrAccessibleAfterFirstUnlock
401 Handler Pattern
When an API returns 401 Unauthorized:
- Retrieve refresh token from Keychain
- Call your
/refreshendpoint - On success: store new refresh token, retry original request
- On failure: delete tokens, redirect to login
Token Rotation (Server-Side)
Each refresh request should return a new refresh token and immediately invalidate the old one. If an attacker replays a used token, the server detects the race condition and revokes the entire session.
✅Three Things You Can Do This Week
1. Centralize Keychain Access
Create a dedicated AuthTokenManager that strictly handles writing, reading, and deleting tokens from the Keychain. Never use UserDefaults for tokens.
2. Implement an Aggressive 401 Handler
When an API call returns 401 Unauthorized, automatically attempt to use the Refresh Token to get a new Access Token and retry the original request. If the refresh fails, then log the user out.
3. Audit Server-Side Revocation
Ensure your server can track and destroy sessions granularly. If a user reports their device stolen, your backend should immediately invalidate that device's Refresh Token.
🎯Key Takeaways
- 1.Long-lived JWTs are a security antipattern on mobile. The exposure window is too large.
- 2.Use the Dual-Token Model: short-lived Access Tokens for API calls, long-lived Refresh Tokens for persistence.
- 3.Store Refresh Tokens in the Keychain only. Never UserDefaults.
- 4.Implement Token Rotation to make each Refresh Token single-use, enabling theft detection.
📱About Sandboxed
Sandboxed is a podcast for people who actually ship iOS apps and care about how secure they are in the real world.
Each episode, we take one practical security topic — like secrets, auth, or hardening your build chain — and walk through how it really works on iOS, what can go wrong, and what you can do about it this week.
If that sounds like your kind of thing, subscribe to stay ahead of the quiet, boring changes that add up to real security wins.
Ready to dive deeper?
We've now built a secure authentication flow using passwords and tokens. But what if we could get rid of passwords entirely? In Episode 7, we'll talk about Passkeys on iOS: how the new WebAuthn standard leverages the Secure Enclave and Face ID to completely eliminate the need for storing tokens and passwords.