# 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

Maria Paktiti

Our [tutorial on native AuthKit sign-in](/content/blog/ios-sdk-authkit-sign-in-tutorial/index.html) 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:

```clike
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:

```clike
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](https://workos.github.io/workos-ios/documentation/workos/) 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](/content/docs/authkit/sessions/index.html) and [Session resilience](/content/docs/authkit/session-resilience/index.html) in the WorkOS docs.
