In this article
August 18, 2026
August 18, 2026

How to store and refresh session tokens correctly with the WorkOS iOS SDK

A practical guide to Keychain storage, refresh token rotation, and terminal vs. transient failures on iOS

Explore with AI
Open in ChatGPT
Open in Claude
Open in Perplexity

Our tutorial on native AuthKit sign-in covered wiring up the WorkOS iOS SDK's PublicClient to authenticate a user. That flow ends with an authenticated session: an access token and a refresh token. What you do with those tokens next matters as much as the sign-in flow itself.

Two mistakes are common on iOS: storing tokens in UserDefaults instead of the Keychain, and treating every failed refresh as a reason to sign the user out. Both are avoidable once you understand how WorkOS handles token rotation and failure classification.

What you get back

A successful authentication returns a JSON Web Token (JWT) access token and a refresh token. The access token carries several claims worth knowing:

  • sub: the WorkOS user ID
  • sid: the session ID, used when signing out
  • iss: the issuer (https://api.workos.com/, or your custom auth domain)
  • org_id: the organization selected at sign-in, if applicable
  • role and permissions: the user's role and permissions for that organization
  • exp and iat: standard expiry and issued-at claims

Keep the access token short-lived. WorkOS recommends a short access token duration precisely so that changes to a session (a revoked role, a suspended user) show up quickly rather than staying valid for hours.

Where to store tokens on iOS

Store both tokens in the Keychain, not UserDefaults. UserDefaults is backed by an unencrypted plist file; the Keychain is hardware-backed and the standard place for credentials on Apple platforms. A minimal wrapper looks like this:

  
import Security

enum TokenKeychain {
    private static let service = "com.yourapp.workos"

    static func set(_ value: String, for key: String) {
        let data = Data(value.utf8)
        let query: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrService as String: service,
            kSecAttrAccount as String: key
        ]
        SecItemDelete(query as CFDictionary)
        var attributes = query
        attributes[kSecValueData as String] = data
        SecItemAdd(attributes as CFDictionary, nil)
    }

    static func get(_ key: String) -> String? {
        let query: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrService as String: service,
            kSecAttrAccount as String: key,
            kSecReturnData as String: true
        ]
        var result: AnyObject?
        guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess,
              let data = result as? Data else { return nil }
        return String(data: data, encoding: .utf8)
    }

    static func clear(_ key: String) {
        let query: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrService as String: service,
            kSecAttrAccount as String: key
        ]
        SecItemDelete(query as CFDictionary)
    }
}
  

Refresh tokens rotate on every use

Every time you exchange a refresh token for a new access token, WorkOS returns a new refresh token too, and retires the one you sent. Always persist the newly returned refresh token and discard the old one; never reuse a refresh token you've already exchanged.

There's one deliberate exception: a 30-second grace period after each exchange. If the same refresh token is replayed within that window (two concurrent requests both noticing an expired access token, or a retried request after a slow network), WorkOS returns the same rotated tokens instead of an error, so retries and race conditions don't tear down the session. After the grace period, replaying a spent token is terminal and returns an invalid_grant error.

Terminal vs. transient failures

This is the part most integrations get wrong: not every failed refresh means the session is over. WorkOS draws a hard line between two categories of failure.

  • Terminal failures mean the session is genuinely done and the user needs to sign in again. This is an OAuth invalid_grant error at HTTP 400, for a revoked, expired, or already-consumed refresh token.
  • Transient failures mean the request itself didn't complete, but the session is still valid. The correct response is to retry, not to sign the user out. These include network errors and timeouts, 429 Too Many Requests from brief refresh contention, and 5xx errors from a temporary upstream issue.
Response Classification What to do
200 OK with new tokens Success Persist the rotated refresh token and continue
400 with invalid_grant Terminal Clear the Keychain and redirect to sign-in
Timeout or network error Transient Keep the session; retry with backoff
429 Too Many Requests Transient Keep the session; back off and retry
500 / 502 / 503 / 504 Transient Keep the session; retry with backoff
Anything else unexpected Treat as terminal Clear the Keychain and redirect to sign-in

Backend-style SDKs, including workos-ios, are bring-your-own-handling: the refresh call returns a typed result and never clears the session for you. Transient transport errors get retried internally first; what surfaces to you is either success or a failure you still have to classify. That's different from the turnkey web framework integrations (authkit-nextjs, authkit-js, and similar), which handle this distinction automatically.

In practice, that means your refresh handling on iOS should look roughly like this:

  
let result = try await workos.authenticateWithRefreshToken(
    refreshToken: TokenKeychain.get("refreshToken")!
)

switch result {
case .success(let authentication):
    TokenKeychain.set(authentication.accessToken, for: "accessToken")
    TokenKeychain.set(authentication.refreshToken, for: "refreshToken")
case .terminal:
    TokenKeychain.clear("accessToken")
    TokenKeychain.clear("refreshToken")
    // Route back to the sign-in screen.
case .transient:
    // Keep the existing tokens in place and retry with backoff
    // on the next request rather than signing the user out.
    break
}
  

Treat the method name and the shape of result above as illustrative. Check the SDK's current DocC reference for the exact refresh method signature and result type in the version you install, generated SDKs iterate quickly, and the point that matters (classify before you clear the session) holds regardless of the exact API surface.

Signing out

When a user signs out, pull the sid claim from the access token, clear both tokens from the Keychain, and end the session on the WorkOS side through the logout endpoint so the session doesn't just disappear locally while staying valid on the server.

Configure session lifetimes to match your app

In the WorkOS dashboard, under your application's Sessions tab, you control three things:

  • maximum session length (how long before a user must sign in again, full stop)
  • access token duration (how long a single access token is valid before it needs refreshing)
  • and inactivity timeout (how long a session can go without a refresh before it's considered abandoned)

Shorter access token durations mean permission and role changes propagate faster; just weigh that against the extra refresh traffic on a mobile network.

The takeaway

Two rules cover most of what matters here: store tokens in the Keychain, not UserDefaults, and only clear a session on a terminal invalid_grant, never on a timeout or a 5xx. Get those two right and the rest of the session lifecycle mostly takes care of itself.

Further reading: Sessions and Session resilience in the WorkOS docs.