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:
- getting the bearer token from an initialized SDK;
- what happens to the refresh token;
- validating the bearer token on your backend with the JWKS endpoint;
- 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.
- Kotlin
- Typescript
val bearerToken: String = sdk.auth.getBearerToken()
const bearerToken: string = await sdk.auth.getBearerToken()
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:
- Read the
kidfrom the token header and find the key with the samekidin the JWKS. Cache the key set. If thekidis unknown, fetch the key set again, because the signing key may have rotated. - Check the RS256 signature with that key. Accept only
RS256. - Check the
expclaim (allowing a small clock skew). - 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:
| Claim | Meaning |
|---|---|
u | id of the user |
g | id of the group the user is logged in to |
exp | expiration 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.
- Typescript
- Kotlin
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,
}
}
import com.nimbusds.jose.JWSAlgorithm
import com.nimbusds.jose.jwk.source.JWKSourceBuilder
import com.nimbusds.jose.proc.JWSVerificationKeySelector
import com.nimbusds.jose.proc.SecurityContext
import com.nimbusds.jwt.proc.DefaultJWTClaimsVerifier
import com.nimbusds.jwt.proc.DefaultJWTProcessor
import java.net.URI
// Caches the key set and refetches it when an unknown `kid` shows up
val cardinalJwks = JWKSourceBuilder
.create<SecurityContext>(URI("https://api.icure.cloud/.well-known/jwks.json").toURL())
.build()
val jwtProcessor = DefaultJWTProcessor<SecurityContext>().apply {
jwsKeySelector = JWSVerificationKeySelector(JWSAlgorithm.RS256, cardinalJwks)
// No required issuer/audience: Cardinal tokens only need a valid signature and `exp`
jwtClaimsSetVerifier = DefaultJWTClaimsVerifier(null, null, setOf("exp", "u", "g"))
}
fun authenticate(bearerToken: String): Pair<String, String> {
// Throws if the signature or `exp` is invalid
val claims = jwtProcessor.process(bearerToken, null)
return claims.getStringClaim("u") to claims.getStringClaim("g")
}
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()
}
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:
ualone 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. Usegandu, for example"$g:$u".- Only read
gandufrom 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.
- Kotlin
- Typescript
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()
}
const TIME_TO_LIVE_MS = 5 * 60 * 1000
const userCache = new Map<string, { user: any; expiresAt: number }>()
export async function getCurrentUser(authorizationHeader: string | undefined) {
// Checks the signature and `exp` first: the key comes from a validated token
const { userId, groupId } = await authenticate(authorizationHeader)
const key = `${groupId}:${userId}`
const cached = userCache.get(key)
if (cached && cached.expiresAt > Date.now()) return cached.user
const bearerToken = authorizationHeader!.replace(/^Bearer /, '')
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()
userCache.set(key, { user, expiresAt: Date.now() + TIME_TO_LIVE_MS })
return user
}
export async function getRoles(authorizationHeader: string | undefined): Promise<string[]> {
return (await getCurrentUser(authorizationHeader)).systemMetadata?.roles ?? []
}
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.