Sandboxed – iOS Security for Builders

Episode 6

🔐 Beyond JWTs: Designing Secure Mobile Authentication

Mobile apps demand long, persistent sessions, but long-lived access tokens are a major security risk. We break down the Dual-Token model and why token rotation is your ultimate defense.

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. 1.Verifies RefreshToken_A
  2. 2.Issues the new Access Token
  3. 3.Issues a brand new RefreshToken_B and immediately invalidates RefreshToken_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

🔐 OAuth 2.0 / Token Rotation Specs

Key Implementation Patterns

Keychain Token Storage

Store refresh tokens using the Keychain Services API with these key attributes:

  • kSecClass: kSecClassGenericPassword
  • kSecAttrService — unique identifier for your token
  • kSecAttrAccessible: kSecAttrAccessibleAfterFirstUnlock

401 Handler Pattern

When an API returns 401 Unauthorized:

  1. Retrieve refresh token from Keychain
  2. Call your /refresh endpoint
  3. On success: store new refresh token, retry original request
  4. 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.

Stay in the Loop

Get iOS security insights, new episode alerts, and exclusive content delivered to your inbox.

No spam. Unsubscribe anytime.

Beyond JWTs: Designing Secure Mobile Authentication | Sandboxed Podcast