In Episode 3, we compare all three storage options, walk through real-world tradeoffs, and give you a simple decision framework you can use with your team this week.
🎯This episode: Where should your tokens live?
Your app probably has several kinds of little secrets that decide what the backend will let you do:
- Access tokens – short-lived OAuth tokens for API calls
- Refresh tokens – longer-lived credentials that mint new access tokens
- Session identifiers or cookies – from legacy web backends
- Device or installation IDs – used for fraud checks, push, or rate limiting
They all look roughly the same when you log them: long random strings. But they don't all have the same impact if they leak.
- If your access token leaks, an attacker might get a few minutes or hours of access.
- If your refresh token leaks, they might get access for weeks or months.
- If your session cookie leaks, they might bypass your login screen entirely.
📦The three storage options
UserDefaults – Great for settings, bad for secrets
UserDefaults was never designed as a secret store. It's basically a property list file inside your app's container.
If someone can read your app's container—through a jailbreak, a device backup, or a compromised Mac—they can read those preference files.
If a value lets you act as the user, it's not a UserDefaults value.
Use UserDefaults for: feature flags, “hasShownOnboarding”, selected theme, analytics opt-in.
Files and databases – Powerful, but extra work needed
Storing tokens directly in your database is almost always a smell. If someone copies that database from a backup or jailbroken device, they now have a pile of tokens.
Better approach: store an opaque identifier in the database, and keep the actual secret in the Keychain.
Keychain – The right place for tokens
The Keychain is a system-wide, encrypted store for small pieces of sensitive data. Each item can have its own access control and accessibility settings.
Why Keychain is better:
- Items are encrypted at rest using keys managed by the system
- Fine-grained control over when items are available (e.g., only when unlocked)
- Items are tied to your app or access group
- Choose whether items are device-only or synced via iCloud
🧭A simple decision framework
For any value you're about to persist, ask:
- Does this value let someone act as the user or impersonate the device?
If yes, treat it as a secret. - Can I easily recreate it if it's lost?
You can always ask the user to log in again. - How long is it valid, and what's the blast radius if it leaks?
Short-lived, heavily-scoped tokens are less risky than long-lived “super tokens”.
Practical mapping
| Token Type | Storage | Accessibility |
|---|---|---|
| Access tokens | Keychain | After first unlock, this device only |
| Refresh tokens | Keychain | Same or stricter than access token |
| Session cookies | Keychain or system cookie store | — |
| Device IDs (security-sensitive) | Keychain | After first unlock, this device only |
| Feature flags, preferences | UserDefaults | — |
💻Code samples
Official Implementation References
📚 Apple Developer Documentation
- Keychain Services → Complete guide to storing sensitive data securely
- Adding a Password to the Keychain → Official sample code for SecItemAdd
- Searching for Keychain Items → Official sample code for SecItemCopyMatching
- kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly → Recommended accessibility level for tokens
🔧 Apple Sample Projects
- Using the Keychain to Manage User Secrets → Full working sample project
Key API Pattern
The Keychain Services API follows a dictionary-based pattern. You build a query dictionary with keys like kSecClass,kSecAttrService, andkSecAttrAccessible, then pass it to one of four functions:
SecItemAdd— Store a new itemSecItemCopyMatching— Retrieve an itemSecItemUpdate— Update an existing itemSecItemDelete— Remove an item
See Apple's documentation links above for complete, copy-paste-ready implementations.
What NOT to do
❌ Never store tokens in UserDefaults. Values end up in a plist file inside the app container, which is not designed to store secrets. These values are:
- Unencrypted on disk
- Included in device backups
- Accessible to anyone with filesystem access (jailbroken devices, forensic tools)
Migrating from UserDefaults
If your app currently stores tokens in UserDefaults, migrate them to Keychain on first launch:
- Check if the legacy UserDefaults key exists
- Read the token value
- Save it to Keychain using
SecItemAdd - Delete the UserDefaults entry with
removeObject(forKey:)
Call this once early in your app lifecycle, for example in application(_:didFinishLaunchingWithOptions:).
✅Four things you can do this week
1. Audit where your tokens live today
Search your codebase for "accessToken" or "refreshToken" in UserDefaults code. Make a table: token type, current storage, desired storage.
2. Move the high-impact tokens to Keychain
Start with access tokens that can hit financial or health endpoints, and refresh tokens that keep sessions alive for a long time.
3. Choose sensible Keychain accessibility settings
As a default, “after first unlock, this device only” is a good balance for most apps. Document this decision so future teammates don't have to guess.
4. Document what is allowed in UserDefaults
Add one line to your README:
“UserDefaults must not contain access tokens, refresh tokens, passwords, session cookies, or secrets. It is only for preferences, feature flags, and non-sensitive state.”
📱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?
In Episode 4, we ask a slightly uncomfortable question: “What happens to all of this if the device is jailbroken?”