Skip to main content

Bearer tokens and JWKS validation

The SDK logs in the user and manages the JSON Web Tokens (JWTs) it uses to call the Cardinal backend. Sometimes you need that identity outside the SDK, for example to call your own backend on behalf of the logged-in user, giving you the possibility to use Cardinal also for authentication and access control on your backend. To do so, your application will send the Cardinal bearer token along with each request, and your backend will check that it's not expired and that its signature against the public keys that Cardinal publishes at /.well-known/jwks.json.

This page covers:

  1. getting the bearer token from an initialized SDK;
  2. what happens to the refresh token;
  3. validating the bearer token on your backend with the JWKS endpoint;
  4. using the bearer token to get the user's roles, and caching them.

Prerequisites​

You need an SDK initialized with a JWT-based authentication method (credentials, authentication process or secret provider). See Initialize the SDK.

Get the bearer token​

Call sdk.auth.getBearerToken() to get the bearer token. It is the same JWT the SDK sends in the Authorization: Bearer ... header of its own requests.

val bearerToken: String = sdk.auth.getBearerToken()
note

sdk.auth is available in Kotlin and TypeScript. The Python and Dart wrappers ship an AuthApi class, but their CardinalSdk does not expose it yet.

Things to keep in mind:

  • Bearer tokens are short-lived. The SDK returns the token it has cached. It only gets a new one once the cached token has expired, so the token you get may expire soon. Call getBearerToken() right before each request to your backend instead of storing the token, and have your backend reject expired tokens (your client can then retry with a new one).
  • Calls fail on non-JWT authentication methods. If the SDK was not initialized with a JWT-based authentication provider, the call throws an UnsupportedOperationException.
  • The token is bound to a group and a scope. After sdk.switchGroup(...) or a scope change, get the bearer token from the SDK instance that switch or change returned. That token carries the new group (and data owner scope).

What about the refresh token?​

Cardinal also issues a refresh token at login. The SDK keeps it internally and uses it to get new bearer tokens, to switch groups and to change scopes. The public SDK API does not expose it, and you do not need it:

  • The refresh token is signed with a different key from bearer tokens. That key is not published at /.well-known/jwks.json, so a third party cannot validate a refresh token.
  • Only the Cardinal backend uses refresh tokens. Forwarding a refresh token to another service would hand that service a long-lived credential for no benefit.

If your backend has to act for the user after the bearer token expires, have the client send a new bearer token with each request. If your app needs to log the user back in later, use a long-lived token from sdk.user.getToken(...) (see Persistent user session).

Validate the bearer token with JWKS​

The Cardinal cloud backend publishes the public keys that verify its bearer tokens as a standard JSON Web Key Set:

GET https://api.icure.cloud/.well-known/jwks.json
{
"keys": [
{
"kty": "RSA",
"use": "sig",
"alg": "RS256",
"kid": "rsa-key-2026",
"n": "...",
"e": "AQAB"
}
]
}

You can also discover the URL from the Cardinal discovery document at GET /.well-known, which returns a jwksUri field. This is not an OpenID Connect discovery document, and there is no /.well-known/openid-configuration.

To validate a token:

  1. Read the kid from the token header and find the key with the same kid in the JWKS. Cache the key set. If the kid is unknown, fetch the key set again, because the signing key may have rotated.
  2. Check the RS256 signature with that key. Accept only RS256.
  3. Check the exp claim (allowing a small clock skew).
  4. Read the user's identity from the Cardinal claims below.

Cardinal bearer tokens do not carry the standard iss, aud or sub claims, so don't make your JWT library require them. Cardinal uses these short claim names instead:

ClaimMeaning
uid of the user
gid of the group the user is logged in to
expexpiration time, in seconds since epoch

The examples below use jose in Node.js and Nimbus JOSE + JWT in Kotlin/JVM. Any library that supports JWKS works the same way.

import { createRemoteJWKSet, jwtVerify } from 'jose'

// Fetched once and cached; refetched automatically when an unknown `kid` shows up
const cardinalJwks = createRemoteJWKSet(new URL('https://api.icure.cloud/.well-known/jwks.json'))

export async function authenticate(authorizationHeader: string | undefined) {
const token = authorizationHeader?.replace(/^Bearer /, '')
if (!token) throw new Error('Missing bearer token')
// Checks the signature and `exp`; throws if either is invalid
const { payload } = await jwtVerify(token, cardinalJwks, {
algorithms: ['RS256'],
clockTolerance: 30,
})
return {
userId: payload.u as string,
groupId: payload.g as string,
}
}
note

A valid signature proves that Cardinal issued the token for that user. It does not check what the user is allowed to do in your backend. Apply your own authorization rules to the u and g claims, or fetch the user's roles as described in the next section.

Get more information about the user​

The token only carries the user id and the group id. To make authorization decisions based on the user's roles (or on any other property of the User), your backend can call Cardinal with the user's own bearer token and ask for the current user. The backend then sees exactly what the user is allowed to see, and you don't need an administrator account in your backend.

Use the bearer token with an unbound SDK​

On a Kotlin/JVM backend, use a CardinalUnboundBaseSdk. Unlike a regular SDK, it is not bound to one user: it asks its authentication provider for a token before each request, so a single instance can serve all your users. The provider below reads the bearer token from the coroutine context, where your request handler puts it.

import com.icure.cardinal.sdk.CardinalUnboundBaseSdk
import com.icure.cardinal.sdk.auth.JwtBearer
import com.icure.cardinal.sdk.auth.JwtBearerAndRefresh
import com.icure.cardinal.sdk.auth.services.AuthProvider
import com.icure.cardinal.sdk.auth.services.JwtBasedAuthProvider
import com.icure.cardinal.sdk.auth.services.TokenBasedAuthService
import com.icure.cardinal.sdk.model.embed.AuthenticationClass
import com.icure.cardinal.sdk.options.AuthenticationMethod
import com.icure.cardinal.sdk.utils.RequestStatusException
import com.icure.utils.InternalIcureApi
import io.ktor.client.request.HttpRequestBuilder
import io.ktor.client.request.bearerAuth
import kotlin.coroutines.AbstractCoroutineContextElement
import kotlin.coroutines.CoroutineContext
import kotlinx.coroutines.currentCoroutineContext

// Carries the bearer token of the user who sent the current request
class CardinalBearerToken(val token: String) : AbstractCoroutineContextElement(Key) {
companion object Key : CoroutineContext.Key<CardinalBearerToken>
}

@OptIn(InternalIcureApi::class)
object BearerTokenFromContextAuthProvider : JwtBasedAuthProvider {
private suspend fun tokenFromContext(): String =
currentCoroutineContext()[CardinalBearerToken]?.token
?: throw IllegalStateException("No Cardinal bearer token in the coroutine context")

private val authService = object : TokenBasedAuthService<JwtBearer> {
override suspend fun setAuthenticationInRequest(builder: HttpRequestBuilder, authenticationClass: AuthenticationClass?) {
builder.bearerAuth(tokenFromContext())
}

override suspend fun getToken(): JwtBearer = JwtBearer(tokenFromContext())

// The backend cannot refresh the user's token: let the request fail, the client will retry with a new token
override suspend fun invalidateCurrentAuthentication(error: RequestStatusException, requiredAuthClass: AuthenticationClass?) {
throw error
}
}

override fun getAuthService(): TokenBasedAuthService<JwtBearer> = authService

override suspend fun getBearerAndRefreshToken(): JwtBearerAndRefresh =
throw UnsupportedOperationException("The refresh token is not available on the backend")

override suspend fun switchGroup(newGroupId: String): AuthProvider =
throw UnsupportedOperationException("Not supported on an unbound SDK")

override suspend fun changeScope(dataOwnerId: String): AuthProvider =
throw UnsupportedOperationException("Not supported on an unbound SDK")
}

// Initialize it once, when your backend starts
@OptIn(InternalIcureApi::class)
suspend fun initializeUnboundSdk(): CardinalUnboundBaseSdk = CardinalUnboundBaseSdk.initialize(
baseUrl = "https://api.icure.cloud",
authenticationMethod = AuthenticationMethod.UsingAuthProvider(BearerTokenFromContextAuthProvider),
)

Then wrap the SDK calls with withContext(CardinalBearerToken(...)) and call sdk.user.getCurrentUser(). It returns the User the token was issued for. Its roles are in systemMetadata: systemMetadata.roles holds the ids of the roles of the user (including the roles inherited from the group when systemMetadata.inheritsRoles is true), and systemMetadata.isAdmin tells whether the user is an administrator.

import com.icure.cardinal.sdk.model.User
import kotlinx.coroutines.withContext

suspend fun currentUser(unboundSdk: CardinalUnboundBaseSdk, bearerToken: String): User =
withContext(CardinalBearerToken(bearerToken)) {
unboundSdk.user.getCurrentUser()
}
caution

CardinalUnboundBaseSdk and AuthenticationMethod.UsingAuthProvider are marked @InternalIcureApi: you need @OptIn(InternalIcureApi::class) to use them, and their signatures may change between SDK versions. The unbound SDK only exposes the base (non-encrypting) APIs, and it is only available in Kotlin.

On a backend that is not written in Kotlin, call the REST endpoint directly with the same token:

const response = await fetch('https://api.icure.cloud/rest/v2/user/current', {
headers: { Authorization: `Bearer ${bearerToken}` },
})
if (!response.ok) throw new Error(`Cannot get the current user: ${response.status}`)
const user = await response.json()
const roles: string[] = user.systemMetadata?.roles ?? []

Cache the user information​

Getting the current user costs a request to Cardinal, and you don't want to make it for each request your backend receives. Cache the result instead, using the g and u claims of the validated token as the key:

  • u alone is not enough: a user id is only unique inside its group, and a user that has access to several groups can have different roles in each one. Use g and u, for example "$g:$u".
  • Only read g and u from a token whose signature and expiration you have already checked (see Validate the bearer token with JWKS). Never build the cache key from an unverified token, or a forged token could read the cached information of another user.
  • Give the entries a short time to live (a few minutes). The cache doesn't know when an administrator changes the roles of a user: the change is only visible once the entry expires.
  • Don't use the bearer token itself as the key: it changes every time the SDK gets a new one, so the cache would almost never be hit.

The example below uses the authenticate function and the unbound SDK defined above, with a minimal in-memory cache. In production, you can use a library such as Caffeine, or a shared cache such as Redis if you run several instances of your backend.

import com.icure.cardinal.sdk.model.User
import java.util.concurrent.ConcurrentHashMap
import kotlin.time.Duration
import kotlin.time.Duration.Companion.minutes
import kotlin.time.TimeSource
import kotlinx.coroutines.withContext

class CurrentUserCache(
private val unboundSdk: CardinalUnboundBaseSdk,
private val timeToLive: Duration = 5.minutes,
) {
private class Entry(val user: User, val expiresAt: TimeSource.Monotonic.ValueTimeMark)

private val entries = ConcurrentHashMap<String, Entry>()

suspend fun getUser(bearerToken: String): User {
// Checks the signature and `exp` first: the key comes from a validated token
val (userId, groupId) = authenticate(bearerToken)
val key = "$groupId:$userId"
entries[key]?.takeIf { it.expiresAt.hasNotPassedNow() }?.let { return it.user }
val user = withContext(CardinalBearerToken(bearerToken)) {
unboundSdk.user.getCurrentUser()
}
entries[key] = Entry(user, TimeSource.Monotonic.markNow() + timeToLive)
return user
}

suspend fun getRoles(bearerToken: String): Set<String> =
getUser(bearerToken).systemMetadata?.roles.orEmpty()
}
info

The JWKS endpoint was added to the Cardinal cloud backend in 2026. Before that, the backend exposed the same public key as a PEM string at GET /rest/v2/auth/publicKey/authJwt, and that endpoint still works. Prefer the JWKS endpoint: it includes the key id, and standard libraries can use it as is.