Sandboxed – iOS Security for Builders

Episode 3

Storing Tokens Safely: Keychain vs Files vs UserDefaults

“Should I put my access token in Keychain, a file, or just UserDefaults?” It's one of the most common questions—and one of the most commonly answered wrong.

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:

  1. Does this value let someone act as the user or impersonate the device?
    If yes, treat it as a secret.
  2. Can I easily recreate it if it's lost?
    You can always ask the user to log in again.
  3. 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 TypeStorageAccessibility
Access tokensKeychainAfter first unlock, this device only
Refresh tokensKeychainSame or stricter than access token
Session cookiesKeychain or system cookie store
Device IDs (security-sensitive)KeychainAfter first unlock, this device only
Feature flags, preferencesUserDefaults

Code samples

Minimal Keychain helper for tokens

Swift
import Foundation
import Security

enum KeychainError: Error {
    case unexpectedStatus(OSStatus)
    case noData
}

struct TokenKeychain {
    static let service = "com.yourcompany.yourapp.tokens"

    static func saveToken(_ token: String, account: String) throws {
        let data = Data(token.utf8)

        // Delete any existing item first to avoid duplicates
        try? deleteToken(account: account)

        let query: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrService as String: service,
            kSecAttrAccount as String: account,
            // "After first unlock, this device only" is a solid default
            kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly,
            kSecValueData as String: data
        ]

        let status = SecItemAdd(query as CFDictionary, nil)
        guard status == errSecSuccess else {
            throw KeychainError.unexpectedStatus(status)
        }
    }

    static func loadToken(account: String) throws -> String {
        let query: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrService as String: service,
            kSecAttrAccount as String: account,
            kSecReturnData as String: kCFBooleanTrue as Any,
            kSecMatchLimit as String: kSecMatchLimitOne
        ]

        var item: CFTypeRef?
        let status = SecItemCopyMatching(query as CFDictionary, &item)

        guard status != errSecItemNotFound else {
            throw KeychainError.noData
        }
        guard status == errSecSuccess else {
            throw KeychainError.unexpectedStatus(status)
        }

        guard let data = item as? Data,
              let token = String(data: data, encoding: .utf8) else {
            throw KeychainError.noData
        }

        return token
    }

    static func deleteToken(account: String) throws {
        let query: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrService as String: service,
            kSecAttrAccount as String: account
        ]

        let status = SecItemDelete(query as CFDictionary)
        guard status == errSecSuccess || status == errSecItemNotFound else {
            throw KeychainError.unexpectedStatus(status)
        }
    }
}

Usage:

Swift
// Store an access token
try? TokenKeychain.saveToken("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", account: "access_token")

// Load it later
if let token = try? TokenKeychain.loadToken(account: "access_token") {
    // Use token in Authorization header
}

Migrating a token from UserDefaults to Keychain

Swift
struct TokenMigrator {
    private static let legacyKey = "accessToken" // Old UserDefaults key

    static func migrateAccessTokenIfNeeded() {
        let defaults = UserDefaults.standard

        guard let legacyToken = defaults.string(forKey: legacyKey),
              !legacyToken.isEmpty else {
            return // Nothing to migrate
        }

        // Save into Keychain
        do {
            try TokenKeychain.saveToken(legacyToken, account: "access_token")
            // Remove from UserDefaults to reduce exposure
            defaults.removeObject(forKey: legacyKey)
            defaults.synchronize()
        } catch {
            // Log this internally, but avoid logging the token itself
            print("Token migration failed: \(error)")
        }
    }
}

Call this once early in your app lifecycle, for example in application(_:didFinishLaunchingWithOptions:).

What NOT to do in UserDefaults

Swift – ❌ Don't do this
// ❌ Don't do this:
let defaults = UserDefaults.standard
defaults.set("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", forKey: "accessToken")
defaults.set("super-secret-refresh-token", forKey: "refreshToken")

// These values end up in a plist file inside the app container,
// which is not designed to store secrets like tokens or passwords.

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're asking a slightly uncomfortable question: “What happens to all of this if the device is jailbroken?”

Coming soon — subscribe to the newsletter to get notified when it drops.

Stay in the Loop

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

No spam. Unsubscribe anytime.

Storing Tokens Safely: Keychain vs Files vs UserDefaults | Sandboxed Podcast