---
title: Kindly iOS SDK — Authentication Reference
slug: sdk/ios-sdk-skill-auth
description: Complete reference for authenticated chat sessions in the Kindly iOS SDK. Use when wiring up AuthTokenCallback or AuthCredentialsCallback, supplying an access token or issuer, refreshing JWTs, calling saveAuthToken/saveAuthCredentials manually…
createdAt: 2026-11-04T11:40:19.256Z
---

Deep reference for authenticated Kindly chat sessions on iOS. Companion to [SKILL.md](https://docs.kindly.ai/sdk/ios-sdk-skill.md).

Your backend or identity provider issues short-lived credentials that prove the chat user's identity; the SDK calls a closure you provide whenever it needs fresh ones.

The Kindly platform verifies those credentials in one of three ways, configured per bot:

| Strategy              | What you return                                                                                       | Verified by                            |
| --------------------- | ----------------------------------------------------------------------------------------------------- | -------------------------------------- |
| **Kindly JWT**        | A JWT your own backend signs                                                                          | Your PEM public key, held by Kindly    |
| **Identity provider** | A JWT from your IdP, on its own or paired with an access token. An access token on its own also works | The IdP's JWKS                         |
| **Introspection**     | An opaque bearer token                                                                                | RFC 7662 introspection against the IdP |

The first is the single-string case, so use `AuthTokenCallback` and you are done. The other two can need more than one value, which is what `KindlyAuthCredentials` and `AuthCredentialsCallback` are for.

***

## The `AuthTokenCallback`

```swift
public typealias AuthTokenCallback = (_ chatId: String, _ promise: Promise<String>) -> Void
```

| Parameter | Meaning                                                                                                                                                |
| --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `chatId`  | The current chat session ID. Pass it through to your backend so it can scope the token to this session.                                                |
| `promise` | A `Promise<String>` (Kindly's lightweight async result). You **must** call `promise.fulfill("…")` with the JWT, or `promise.reject(error)` on failure. |

The SDK awaits the promise for up to 15 seconds. If you never fulfill or reject it gives up at that point and the chat connects anonymously, so resolve the promise either way.

***

## The `AuthCredentialsCallback`

```swift
public typealias AuthCredentialsCallback = (_ chatId: String, _ promise: Promise<KindlyAuthCredentials>) -> Void
```

Identical to `AuthTokenCallback` except for what you fulfil the promise with:

```swift
public struct KindlyAuthCredentials {
    public let idToken: String?      // optional
    public let accessToken: String?  // optional
    public let issuer: String?       // optional
    public let expiresAt: Date?      // optional

    // derived, read-only
    public var bearerToken: String?           // idToken ?? accessToken
    public var hasToken: Bool                 // bearerToken != nil
    public var companionAccessToken: String?  // accessToken, only next to a different idToken
}
```

Every field is optional, but at least one of `idToken` / `accessToken` has to be set. Credentials carrying neither cannot authenticate anything: the SDK reads them as an empty result from your callback, and the chat stays anonymous.

| Field         | Sent as                                                                                                        | When you need it                                                                                                                                                                             |
| ------------- | -------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `idToken`     | `Authorization: Bearer`, when set                                                                              | The OIDC id token, if your provider issues one. For introspection this is the opaque token itself                                                                                            |
| `accessToken` | `X-Access-Token` next to a *different* id token, or `Authorization: Bearer` when it is the only token you have | Dual-token IdP setups, where Kindly forwards this to your webhooks rather than the id token, and providers that issue no id token at all. The Kindly JWT strategy ignores the companion form |
| `issuer`      | `X-Auth-Issuer`                                                                                                | Bots with more than one identity provider configured, and any opaque token, since Kindly cannot read an `iss` claim out of a token it cannot decode                                          |
| `expiresAt`   | not sent                                                                                                       | Opaque tokens. The SDK reads `exp` off a JWT itself, but an opaque token tells it nothing, and a credential with no known expiry is never proactively refreshed                              |

### Which token becomes the bearer

`Authorization` is mandatory on Kindly's auth route, so whichever token you have goes there: `idToken` first, falling back to `accessToken`. A provider that returns **only** an access token is a normal, supported case and not a workaround. That access token becomes the bearer, and the backend verifies it by whichever strategy the bot is configured for.

Three read-only accessors tell you exactly what the SDK will do, so you never have to re-derive the rules:

```swift
credentials.bearerToken           // idToken ?? accessToken, first non-empty. nil if neither is set
credentials.hasToken              // bearerToken != nil, the real "can this authenticate?" check
credentials.companionAccessToken  // the value that will be sent as X-Access-Token, or nil
```

`companionAccessToken` is the access token in its companion role only, so it is `nil` in the two cases that are easy to get wrong:

- the access token is itself the bearer, because no id token was supplied, so there is nothing to accompany
- the access token merely duplicates the id token

That second one has teeth. Kindly verifies the companion against the id token's `at_hash` and rejects a mismatched pair outright, so sending a duplicate fails an auth that would otherwise have succeeded. The SDK drops it for you.

### What goes on the wire

| Header                                   | Sent                         |
| ---------------------------------------- | ---------------------------- |
| `Authorization: Bearer <bearerToken>`    | whenever `hasToken` is true  |
| `X-Access-Token: <companionAccessToken>` | only when a companion exists |
| `X-Auth-Issuer: <issuer>`                | only when you set an issuer  |

Kindly reads the last two on `auth/chat`, `message` and `trigger` only. No other route looks at them.

Wire it the same two ways as the token callback:

```swift
KindlySDK.start(botKey: "YOUR_BOT_KEY", market: "YOUR_MARKET") { chatId, promise in
    Task {
        do {
            let tokens = try await MyAuth.fetchTokens(chatId: chatId)
            promise.fulfill(
                KindlyAuthCredentials(
                    idToken: tokens.idToken,
                    accessToken: tokens.accessToken,
                    issuer: "https://idp.example.com/",
                    expiresAtUnixSeconds: tokens.expiresAt
                )
            )
        } catch {
            promise.reject(error)
        }
    }
}
```

```swift
KindlySDK.config.getAuthCredentials = { chatId, promise in /* … */ }
```

`getAuthCredentials` wins when both it and `getAuthToken` are set.

***

## When the SDK Calls It

The callback fires on:

1. **Initial connection** after `start(...)` — to obtain the first credentials.
2. **Token expiry** — when the stored credentials are about to expire or have been rejected (HTTP 401 from Kindly).
3. **Forced refresh** — internal recovery paths after socket reconnect.

The SDK caches the most recent credentials in the iOS Keychain (automatically, after `promise.fulfill`).

### When "about to expire" actually fires

A credential can expire for three different reasons, and the SDK refreshes on whichever comes first, 30 seconds early:

1. **`expires_at` from `POST auth/chat`**, the backend's own view. This is the earliest expiry across every token it verified, so on a dual-token setup it accounts for the access token dying before the id token does, and on introspection it is the only expiry that exists at all
2. **`KindlyAuthCredentials.expiresAt`**, your hint for tokens the SDK cannot decode
3. **The `exp` claim** of the bearer token, when it is a JWT. That is the id token when you supplied one, and the access token when it is the only token you have

```javascript
refresh when:  now >= min(backend expires_at, your expiresAt, exp claim) - 30s
```

If **none** of the three is known the credential is treated as non-expiring and never proactively refreshed. That is correct for a JWT with no `exp` (the claim is optional per spec) but it is a trap for an opaque token: there is no claim to read, so supply `expiresAt` or the SDK will keep using it until the backend rejects it.

***

## Two Equivalent Wiring Patterns

### Inline at `start()`

```swift
KindlySDK.start(botKey: "YOUR_BOT_KEY", market: "YOUR_MARKET") { chatId, promise in
    Task {
        do {
            let jwt = try await MyAuth.fetchKindlyJWT(chatId: chatId)
            promise.fulfill(jwt)
        } catch {
            promise.reject(error)
        }
    }
}
```

### Set on `KindlySDK.config` after `start()`

```swift
KindlySDK.start(botKey: "YOUR_BOT_KEY", market: "YOUR_MARKET")

KindlySDK.config.getAuthToken = { chatId, promise in
    Task {
        do {
            let jwt = try await MyAuth.fetchKindlyJWT(chatId: chatId)
            promise.fulfill(jwt)
        } catch {
            promise.reject(error)
        }
    }
}
```

Pick the inline form when you have credentials available at SDK boot. Pick the post-`start` form when auth becomes available later (e.g., after the user signs in to your app).

***

## Manual Token Save

If you already have a valid JWT (e.g., received from your own login flow), bypass the callback by saving it directly:

```swift
@discardableResult
public class func saveAuthToken(_ token: String) -> Bool
```

```swift
let saved = KindlySDK.saveAuthToken("eyJhbGciOi...")
if !saved {
    // false when start() has not run yet, or the Keychain refused the write
}
```

`saveAuthToken` writes to the Keychain and the SDK will use that token until it expires or is rejected — at which point it falls back to your `AuthTokenCallback` if one is set.

The credentials equivalent:

```swift
@discardableResult
public class func saveAuthCredentials(_ credentials: KindlyAuthCredentials) -> Bool
```

Both replace the stored credential **wholesale**. Saving an id token alone clears any access token and issuer that were there before. A partial overwrite would pair one credential's access token with another's id token, which the backend rejects outright.

***

## Re-authenticating Mid-Session

The SDK handles expiry on its own. These two are for when the *identity* changes, which it cannot detect:

```swift
@discardableResult public class func authenticate() -> Promise<Void>
@discardableResult public class func deauthenticate() -> Promise<Void>
```

**`authenticate()`** asks your callback for fresh credentials and re-binds them to the live chat. The chat, its history and its socket are all kept. Use it when the user signs in, or switches account, while the chat is already open.

**`deauthenticate()`** posts to `auth/chat/invalidate` to unbind the identity on the backend, then forgets the stored credentials. The chat carries on **anonymously with its history intact**, so this is a sign-out and not an `endChat()`.

Safe to call when already anonymous; it fulfils immediately. If the backend call fails the local credentials are still cleared (a sign-out is never left half done) but the promise rejects, so you can tell the backend did not hear about it.

```swift
func onUserSignedIn() {
    KindlySDK.authenticate().catch { error in
        print("could not re-authenticate: \(error)")
    }
}

func onUserSignedOut() {
    KindlySDK.deauthenticate()
}
```

***

## End-to-End Pattern

```swift
import Kindly

final class MyAuthIntegration {
    static let shared = MyAuthIntegration()

    func bootKindly() {
        KindlySDK.start(botKey: "YOUR_BOT_KEY", market: "YOUR_MARKET") { chatId, promise in
            self.fetchKindlyJWT(chatId: chatId, promise: promise)
        }
    }

    private func fetchKindlyJWT(chatId: String, promise: Promise<String>) {
        Task {
            do {
                let req = try await buildRequest(chatId: chatId)
                let (data, _) = try await URLSession.shared.data(for: req)
                let token = try JSONDecoder().decode(TokenResponse.self, from: data).jwt
                promise.fulfill(token)
            } catch {
                promise.reject(error)
            }
        }
    }

    private func buildRequest(chatId: String) async throws -> URLRequest {
        var req = URLRequest(url: URL(string: "https://your-backend/kindly-jwt")!)
        req.httpMethod = "POST"
        req.setValue("Bearer \(myAppSessionToken)", forHTTPHeaderField: "Authorization")
        req.httpBody = try JSONEncoder().encode(["chatId": chatId])
        return req
    }
}

struct TokenResponse: Decodable { let jwt: String }
```

***

## Hand-off Patterns

### User logs in mid-session

You started Kindly anonymously and the user has now signed in. Set the callback, then call `authenticate()`:

```swift
KindlySDK.config.getAuthToken = { chatId, promise in
    Task {
        let jwt = try? await MyAuth.fetchKindlyJWT(chatId: chatId)
        if let jwt { promise.fulfill(jwt) } else { promise.reject(MyError.noJwt) }
    }
}
KindlySDK.authenticate()
```

This keeps the conversation the user is in the middle of. Ending and restarting the chat also works but throws away the history and issues a new `chatId`, so only do that when you actually want a clean session.

### User logs out

```swift
KindlySDK.kill().then {
    print("SDK fully shut down — call start() again on next sign-in")
}
```

`kill()` tears down sockets, dismisses UI, and clears in-memory caches. It does **not** clear the Keychain. To sign the user out of the chat without tearing anything down, use `deauthenticate()`, which both unbinds the identity on the backend and clears local storage.

***

## Debugging Failed Auth

| Symptom                                                                 | Likely cause                                                                                                                                                                                                       |
| ----------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Chat opens then immediately disconnects                                 | Backend returned a JWT but it was malformed or signed with the wrong key. Check Kindly admin → "Authenticated chat" → public key.                                                                                  |
| `AuthTokenCallback` never called                                        | You set `KindlySDK.config.getAuthToken` **before** `start()`; the config object is replaced inside `start()`. Set it via the `start(...)` overload or after `start()` returns.                                     |
| Promise never resolves                                                  | Your async branch threw before reaching `fulfill/reject`. The SDK gives up after 15s and connects anonymously. Wrap in `do/catch` and always reject on failure.                                                    |
| Token works once then chat freezes                                      | Your backend is returning the same expired token on refresh. Make sure your endpoint generates a new JWT every time it's called.                                                                                   |
| HTTP 401 in logs                                                        | Kindly rejected the token signature/claims. Verify the JWT contains the configured `iss` claim and is signed with the configured algorithm/key.                                                                    |
| `X-Access-Token requires a JWT id_token in Authorization`               | You put an opaque token in `idToken` **and** set `accessToken`. The companion is only valid alongside a JWT id token. For introspection, send the opaque token as `idToken` with an `issuer` and no `accessToken`. |
| Chat stays anonymous although the callback fulfilled                    | The credentials you built carry neither `idToken` nor `accessToken`, so there is nothing to send. Check `hasToken` before fulfilling.                                                                              |
| Auth works with the id token alone, fails when you add the access token | The two values are not a matching pair from the same token response. Kindly checks the companion against the id token's `at_hash`. An access token equal to the id token is fine, the SDK drops it.                |
| Opaque token never refreshes                                            | Nothing to decode, so the SDK has no expiry unless you give it one. Set `expiresAt` on the credentials.                                                                                                            |
| Chat drops to anonymous long before the token's `exp`                   | The backend's `expires_at` is shorter than your claim and it wins. Check what your IdP puts on the access token. On a dual-token setup the earlier of the two is what counts.                                      |

Enable verbose logging to see the auth handshake:

```swift
KindlySDK.verboseLogging = true
```

***

## Security Notes

- **Never embed long-lived secrets in the iOS app.** Mint short-lived JWTs server-side.
- Credentials are stored in the iOS Keychain, scoped to your app. Not reachable from other apps, but readable on a jailbroken device.
- The callback is invoked from a background thread. Use `Task { ... }` (or `DispatchQueue`) — do **not** block by calling synchronous network APIs on the calling thread.
- The `chatId` you receive is generated by the SDK on first connection and is stable for the lifetime of the session.
