---
title: Kindly Android SDK — Authentication Reference
slug: sdk/android-sdk-skill-auth
description: Complete reference for authenticated chat sessions in the Kindly Android SDK. Use when wiring up KindlyAuthTokenProvider or KindlyAuthCredentialsProvider, supplying an access token or issuer, refreshing JWTs, migrating from the deprecated…
createdAt: 2026-11-04T11:39:57.612Z
---

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

Your backend or identity provider issues short-lived credentials that prove the chat user's identity; the SDK calls a provider you supply whenever it needs fresh ones. On Android a provider is a `fun interface` with a single `suspend` function.

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. Use `KindlyAuthTokenProvider` and you are done. The other two can need more than one value, which is what `KindlyAuthCredentialsProvider` is for.

***

## The `KindlyAuthTokenProvider`

```kotlin
fun interface KindlyAuthTokenProvider {
    suspend fun fetchToken(request: AuthTokenRequest): String
}

class AuthTokenRequest(
    val chatId: String,
    val reason: Reason,
) {
    enum class Reason {
        INITIAL_CONNECT,
        EXPIRED,
    }
}
```

| Parameter        | Meaning                                                                                                                                                                                  |
| ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `request.chatId` | The current chat session ID. Pass it through to your backend so it can scope the token to this session.                                                                                  |
| `request.reason` | Why the SDK is asking. `INITIAL_CONNECT` — first token for a new session. `EXPIRED` — the cached token is expired or about to expire; **mint a fresh token, never return a cached one**. |
| **return**       | The JWT as a `String`. Throw an exception to signal failure.                                                                                                                             |

Because `fetchToken` is a `suspend` function, the SDK invokes it from its own coroutine — you don't manage a `CoroutineScope`, `Deferred`, or threading. The SDK still runs auth and socket connection concurrently internally, so startup stays parallel.

### SDK-side guarantees

- **15-second timeout** — if `fetchToken` doesn't return in time, the fetch is abandoned (a warning is logged and a failure metric emitted) and the session continues unauthenticated. A provider that hangs can no longer wedge chat startup.
- **Expired-on-arrival rejection** — if the returned JWT is already expired, the SDK discards it instead of saving it, breaking the refresh loop that would otherwise occur.
- **No-`exp` tokens are valid** — JWTs without an `exp` claim are treated as non-expiring (per the JWT spec, `exp` is optional) and are never proactively refreshed.
- **Background refresh is crash-safe** — an exception thrown during an interceptor-triggered background refresh is caught and logged; it does not crash the host app.

***

## The `KindlyAuthCredentialsProvider`

```kotlin
fun interface KindlyAuthCredentialsProvider {
    suspend fun fetchCredentials(request: AuthTokenRequest): KindlyAuthCredentials
}

data class KindlyAuthCredentials(
    val idToken: String? = null,
    val accessToken: String? = null,
    val issuer: String? = null,
    val expiresAt: Long? = null,   // Unix seconds
)
```

Identical contract to `KindlyAuthTokenProvider`: same `AuthTokenRequest`, same 15-second timeout, same expired-on-arrival rejection. Only what you return differs.

### The four fields

| Field         | When you need it                                                                                                                                                              |
| ------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `idToken`     | The OIDC id token, when your provider issues one. **Optional.** It is the preferred bearer, not a mandatory field                                                             |
| `accessToken` | The access token, when your provider issues one. **Optional.** Alongside an id token it is the dual-token companion; on its own it becomes the bearer                         |
| `issuer`      | Bots with more than one identity provider configured, and any opaque token, because Kindly cannot read an `iss` claim out of a token it cannot decode                         |
| `expiresAt`   | 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. Unix seconds |

What the SDK requires is that **at least one** of `idToken` / `accessToken` is set. Credentials carrying neither cannot authenticate anything, and the chat stays anonymous.

### What actually goes on the wire

Three derived properties spell out the rule, so you can read it rather than re-derive it:

| Property               | Value                                                                                        | Header                                                    |
| ---------------------- | -------------------------------------------------------------------------------------------- | --------------------------------------------------------- |
| `bearerToken`          | `idToken ?: accessToken`, first non-empty, else `null`                                       | `Authorization: Bearer`                                   |
| `hasToken`             | `bearerToken != null`                                                                        | none. This is the validity check, never `idToken != null` |
| `companionAccessToken` | The access token, but only when an id token is also set **and differs from it**, else `null` | `X-Access-Token`                                          |

`Authorization` is mandatory on the backend's auth route, so whichever token you have goes there. `X-Access-Token` is sent only when a real companion exists, and `X-Auth-Issuer` only when you set `issuer`. All three are read by the backend on `auth/chat`, `message` and `trigger`, and nowhere else.

`companionAccessToken` is deliberately null in the two cases that are easy to get wrong: when the access token *is* the bearer, and when it merely duplicates the id token. The backend verifies the companion against the id token's `at_hash` and rejects a mismatched pair outright, so sending a duplicate would fail an auth that would otherwise have succeeded. The SDK drops it for you.

### A provider that issues only an access token

Normal and supported, not a workaround. Leave `idToken` unset and the access token becomes the bearer, verified by whichever strategy the bot is configured for:

```kotlin
KindlyAuthCredentials(
    accessToken = tokens.accessToken,
    issuer = "https://idp.example.com/",
    expiresAt = tokens.expiresAtUnixSeconds,
)
```

Do not copy the same value into both fields to "be safe". That is the duplicate case above, and the only reason it does not break your auth is that the SDK deduplicates it.

### Wiring the credentials provider

```kotlin
import no.kindly.chatsdk.chat.KindlyAuthCredentials
import no.kindly.chatsdk.chat.KindlyAuthCredentialsProvider

KindlySDK.start(
    application = this,
    botKey = "YOUR_BOT_KEY",
    market = "YOUR_MARKET",
    authCredentialsProvider = KindlyAuthCredentialsProvider { request ->
        val tokens = MyAuth.fetchTokens(request.chatId)
        KindlyAuthCredentials(
            idToken = tokens.idToken,
            accessToken = tokens.accessToken,
            issuer = "https://idp.example.com/",
            expiresAt = tokens.expiresAtUnixSeconds,
        )
    },
)
```

A single-JWT integration never needs this type. Keep returning a `String` from `KindlyAuthTokenProvider`. When more than one auth parameter is passed to `start(...)`, precedence is `authCredentialsProvider` > `authTokenProvider` > `authTokenCallback` (deprecated).

***

## When the SDK Calls It

The provider fires on:

1. **Initial connection** after `start(...)` — to obtain the first JWT (`reason = INITIAL_CONNECT`).
2. **Token expiry** — when the cached JWT is about to expire (within a 30s grace window) or has been rejected by the backend (`reason = EXPIRED`).
3. **Forced refresh** — internal recovery paths after socket reconnect (`reason = EXPIRED`).

The SDK persists the most recent credentials in its private `LocalDataStore` (SharedPreferences). You don't need to manage caching yourself.

### 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**, when it is a JWT. The bearer, not the id token specifically: when you supplied only an access token, that token is the bearer and its `exp` is the only claim there is

```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. Supply `expiresAt` or the SDK will keep using it until the backend rejects it.

A token the SDK cannot parse means **expiry unknown**, never "expired". Opaque bearers are a supported credential, not a malformed one, and the SDK will not discard one on arrival for being undecodable. What it cannot do is guess when it dies, which is what `expiresAt` is for.

***

## Wiring the token provider at `start()`

The provider is passed as the `authTokenProvider` parameter on `KindlySDK.start(...)`:

```kotlin
import no.kindly.chatsdk.chat.KindlyAuthTokenProvider

class App : Application() {
    override fun onCreate() {
        super.onCreate()
        KindlySDK.start(
            application = this,
            botKey = "YOUR_BOT_KEY",
            languageCode = "en",
            market = "YOUR_MARKET",
            authTokenProvider = KindlyAuthTokenProvider { request ->
                MyAuth.fetchKindlyJwt(request.chatId)   // suspend call
            },
        )
    }
}
```

***

## Migrating from `AuthTokenCallback` (deprecated)

Earlier SDK versions used a `Deferred`-based callback:

```kotlin
@Deprecated("Use KindlyAuthTokenProvider with KindlySDK.start(authTokenProvider = ...) instead.")
typealias AuthTokenCallback = (chatId: String) -> Deferred<String>
```

**Existing integrations do not break.** The `authTokenCallback` parameter still exists on `start(...)` and is adapted internally to a provider (so it also benefits from the timeout and expired-token guards). It is deprecated — the compiler shows a deprecation warning — and new integrations should use `authTokenProvider`. If both are passed, `authTokenProvider` wins.

Migration is mechanical — drop the scope/`Deferred` plumbing:

```kotlin
// Before (deprecated)
authTokenCallback = { chatId ->
    CoroutineScope(Dispatchers.IO).async { MyAuth.fetchKindlyJwt(chatId) }
}

// After
authTokenProvider = KindlyAuthTokenProvider { request ->
    MyAuth.fetchKindlyJwt(request.chatId)
}
```

***

## Hand-off Patterns

Expiry is handled for you. These two are for when the *identity* changes, which the SDK cannot detect on its own:

```kotlin
fun authenticate(): Deferred<Unit>
fun deauthenticate(): Deferred<Unit>
```

Awaiting either is optional; do it when you need to know the backend agreed.

### User logs in mid-session

You started Kindly anonymously and the user has now signed in. Call `authenticate()`:

```kotlin
fun onUserSignedIn() {
    lifecycleScope.launch {
        runCatching { KindlySDK.authenticate().await() }
            .onFailure { Log.w(TAG, "could not re-authenticate", it) }
    }
}
```

This asks your provider for fresh credentials and re-binds them to the chat that is already open, keeping 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

```kotlin
KindlySDK.deauthenticate()
```

Unbinds the identity on the backend and forgets the stored credentials. The chat carries on **anonymously with its history intact**. This is a sign-out, not an `endChat()`. Safe to call when already anonymous; it completes immediately.

If the backend call fails the local credentials are still cleared (a sign-out is never left half done) but the `Deferred` completes exceptionally, so you can tell the backend did not hear about it.

Use `kill()` instead when you want the SDK itself gone. It tears down sockets, dismisses UI and clears in-memory caches, but does **not** unbind the identity or clear the credential store.

***

## End-to-End Pattern

```kotlin
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.MediaType.Companion.toMediaType
import org.json.JSONObject
import no.kindly.chatsdk.chat.KindlyAuthTokenProvider

object KindlyAuthBridge {
    private val http = OkHttpClient()

    fun provider() = KindlyAuthTokenProvider { request ->
        fetchJwt(request.chatId)
    }

    private suspend fun fetchJwt(chatId: String): String =
        withContext(Dispatchers.IO) {
            val body = JSONObject().put("chatId", chatId)
                .toString()
                .toRequestBody("application/json".toMediaType())

            val req = Request.Builder()
                .url("https://your-backend/kindly-jwt")
                .header("Authorization", "Bearer ${MySession.appToken}")
                .post(body)
                .build()

            http.newCall(req).execute().use { resp ->
                check(resp.isSuccessful) { "Kindly JWT fetch failed: ${resp.code}" }
                JSONObject(resp.body!!.string()).getString("jwt")
            }
        }
}

class App : Application() {
    override fun onCreate() {
        super.onCreate()
        KindlySDK.start(
            application = this,
            botKey = "YOUR_BOT_KEY",
            market = "YOUR_MARKET",
            authTokenProvider = KindlyAuthBridge.provider(),
        )
    }
}
```

***

## 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.                                                                                 |
| Provider never called                                                    | It was passed to a different `start(...)` overload, or `start(...)` was never reached (e.g., crashed earlier in `Application.onCreate`).                                                                          |
| Fetch abandoned after 15s                                                | Your backend is too slow or `fetchToken` blocks indefinitely. The SDK logs `auth token fetch timed out` and continues unauthenticated.                                                                            |
| `already-expired token, discarding` in logs                              | Your backend returned a JWT whose `exp` is already in the past (or within the 30s grace window). Mint tokens with a longer lifetime, and always mint fresh on `reason == EXPIRED`.                                |
| 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` |
| Provider returns only an access token and you are unsure where to put it | `accessToken`, with `idToken` left unset. It becomes the bearer. Do not mirror it into `idToken` as well, that is the duplicate case and the SDK drops the companion anyway                                       |
| Chat stays anonymous, no error anywhere                                  | The credentials carried neither token, so `hasToken` was false and there was nothing to send. Check the branch of your provider that ran                                                                          |
| 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                                      |
| Deprecation warning on `authTokenCallback`                               | Expected — the `Deferred`-based callback is deprecated. It keeps working; migrate to `authTokenProvider` when convenient.                                                                                         |

Enable verbose logging to see the auth handshake:

```kotlin
KindlySDK.verboseLogging = true
```

***

## Security Notes

- **Never embed long-lived secrets in the Android app.** Mint short-lived JWTs server-side.
- Credentials are stored in the SDK's private SharedPreferences (sandboxed to your app). Not accessible to other apps under standard Android permissions.
- `fetchToken` is called from an SDK-managed coroutine off the main thread; still use `withContext(Dispatchers.IO)` around blocking I/O inside your implementation.
- The `chatId` you receive is generated by the SDK on first connection and is stable for the lifetime of the session.
- JWTs without an `exp` claim are accepted and treated as non-expiring. Prefer short-lived tokens with `exp` in production — the no-`exp` path exists for compatibility, not as a recommendation.
