You can sign users in with Keycloak through the Versola OAuth SDK’s OAuthClient by creating a public client in a realm and passing the realm’s issuer to the SDK. This guide is backed by the SDK’s own integration tests, which ran against quay.io/keycloak/keycloak:26.4 in development mode with a purpose-built test realm.
Read Provider guides first for the minimum contract and the redirect setup that is the same for every provider.
What to configure in Keycloak
Realm and issuer
The discovery document is at {base}/realms/{realm}/.well-known/openid-configuration and the issuer is {base}/realms/{realm}. Open the discovery document in a browser and copy its issuer member: that exact value is what you configure. In development mode Keycloak derives the issuer, every endpoint and the login form’s action from the Host of the request, so what you see depends on how you reach it. A production deployment fixes the hostname in its own configuration.
The client
Create an OpenID Connect client for the app. The fields below are what the test realm sets. The names in backticks are the realm-import attributes it uses.
| Setting | Value | Why |
|---|---|---|
| Client authentication | Off (publicClient: true) | A native app cannot keep a secret, and the SDK has no secret to send. |
| Standard flow | On (standardFlowEnabled) | The authorization code flow is the only flow the SDK uses. |
| Implicit flow, direct access grants, service accounts | Off | Not used. The test realm leaves them off. |
| Valid redirect URIs | The exact redirectUri string, one entry per platform if they differ | Keycloak matches exactly and case-sensitively. A wildcard is allowed only at the end of a pattern. |
| Valid post logout redirect URIs | The exact postLogoutRedirectUri (attributes."post.logout.redirect.uris") | Without a registered URI Keycloak does not redirect back after sign-out. |
| PKCE code challenge method | S256 (attributes."pkce.code.challenge.method") | Makes Keycloak refuse a request without S256. The SDK always sends it. |
| Web origins | Empty | A native app makes no cross-origin browser requests. |
Redirect URIs for Android App Links and iOS Universal Links are plain entries in that list. The test realm registers a single https URL. Prefer the https form, as everywhere (redirect setup).
Scopes and refresh tokens
Keycloak issues a refresh token for the authorization code flow by default, without any special scope: the test client asks for openid profile only and receives one. The test realm also lists offline_access as an optional client scope.
offline_access is a different thing on Keycloak: it asks for an offline token. Per the Keycloak documentation an offline token is not bound by the SSO session timeouts, stays valid after the user signs out, must be used at least every 30 days (or within the realm’s Offline Session Idle setting), and requires the user to hold the realm role offline_access. Don’t add it unless you want that behaviour. The SDK never adds it. At create() it logs one warning if scopes_supported lists offline_access and your scopes don’t. For Keycloak the warning says a refresh token may not be issued, and Keycloak issues one regardless.
Refresh token rotation
Rotation is a realm setting: Revoke Refresh Token under Realm settings, Tokens. The test realm sets revokeRefreshToken: true and refreshTokenMaxReuse: 0. With it on, reusing a rotated refresh token is 400 invalid_grant (“Maximum allowed refresh token reuse exceeded”) and revokes the session. The SDK stores each rotated token before it forgets the old one, treats invalid_grant as Reauthenticate(GrantRejected) and never retries it. Set it deliberately.
If the app is killed after Keycloak rotated the token but before the new one was stored, the next refresh is a replay and ends in GrantRejected.
Sign-out
Keycloak’s end-session endpoint is .../protocol/openid-connect/logout, and discovery advertises it. With id_token_hint, a registered post_logout_redirect_uri and state it answers a direct 302 to the post-logout URI carrying state, with no confirmation page. Without an id_token_hint Keycloak shows a confirmation page.
The revocation endpoint is .../protocol/openid-connect/revoke. A public client sends client_id together with token and token_type_hint, and nothing else.
SDK configuration
The issuer below is {base}/realms/{realm} with the realm name example. It has no trailing slash, because Keycloak announces none.
Kotlin
import versola.oauth.OAuthClient
import versola.oauth.OAuthConfiguration
val client = OAuthClient.create(
context,
OAuthConfiguration(
issuer = "https://id.example.com/realms/example",
clientId = "example-app",
redirectUri = "https://app.example.com/oauth",
postLogoutRedirectUri = "https://app.example.com/signed-out",
scopes = setOf("openid", "profile"),
),
)
Swift
import OAuthClient
let configuration = try OAuthConfiguration(
issuer: "https://id.example.com/realms/example",
clientId: "example-app",
redirectUri: "https://app.example.com/oauth",
postLogoutRedirectUri: "https://app.example.com/signed-out",
scopes: ["openid", "profile"]
)
let client = try await OAuthClient.create(
configuration: configuration,
presentationAnchor: appKeyWindow // from the Provider guides page
)
The Swift package product is VersolaOAuth. The module you import is OAuthClient.
Dart
import 'package:versola_oauth/versola_oauth.dart';
final client = await OAuthClient.create(const OAuthConfiguration(
issuer: 'https://id.example.com/realms/example',
clientId: 'example-app',
redirectUri: 'https://app.example.com/oauth',
postLogoutRedirectUri: 'https://app.example.com/signed-out',
scopes: {'openid', 'profile'},
));
Notes on the values
scopes:openidis always added. Addprofile,emailand so on as the client’s scopes allow. Notoffline_access, unless you want an offline token (see Scopes and refresh tokens).postLogoutRedirectUri: set it.sessionEndedin the logout result istrueonly when the browser returns to this URI carrying thestatethe SDK sent. Without it the provider may end the session and the result still saysfalse. On Android under Auth Tab anhttpsvalue must be a verified App Link too.endSessionEndpoint: not needed. Keycloak advertises it.pushedAuthorization: leave the default. Keycloak advertises PAR, soWhenAdvertisedpushes the request, and the integration tests ran that way.tokenBinding: leave the defaultBearer. See the DPoP row below.
What to expect
| Capability | Keycloak 26.4 | What the SDK does |
|---|---|---|
| Refresh token | Issued without offline_access | Authenticated with a refresh token. Rotation as the realm is set. |
| Revocation | Present. A public client sends client_id only | logout() revokes, and tokenRevoked = true. |
| End-session | Present. Direct 302 with state when id_token_hint and a registered URI are sent | sessionEnded = true when the redirect returns with the matching state. |
| UserInfo | Present | userInfo() works, and sub is checked against the ID token. |
| PAR | pushed_authorization_request_endpoint advertised, require_pushed_authorization_requests: false, public client accepted | The default WhenAdvertised pushes the request and opens the browser with client_id and request_uri only. A rejection (for example an unregistered redirect URI) is Configuration(AuthorizationRejected) before any browser opens: Keycloak answers 400 invalid_request “Invalid parameter: redirect_uri”. |
RFC 9207 iss on the redirect | Advertised and sent | Checked on every redirect. |
| DPoP | dpop_signing_alg_values_supported advertised with ES, RS and PS 256 to 512 and EdDSA | The default is Bearer. Asking for DPoP without requiring it uses DPoP, because ES256 is advertised. |
| ID-token algorithm | RS256 by default. The discovery list also names HS256/384/512 and EdDSA | The SDK’s closed set refuses HMAC whatever discovery lists, and accepts RS256. |
Date header on the token response | Absent | The server-time skew correction doesn’t engage. iat and exp are checked against the device clock within clockSkewTolerance. |
| Rate limits | Brute-force detection only (password, OTP), off by default. No 429 or Retry-After in the documentation | Nothing to configure. A 429 or 503 with Retry-After from a proxy in front is honoured (Server errors, 429 and Retry-After) |
Known limits
- A refresh replay after a crash ends in
Reauthenticate(GrantRejected)(see Refresh token rotation). - A realm that forces a required action, an MFA step or a consent screen shows those pages in the browser. The SDK isn’t involved.
How this was checked
The Android integration tests ran against quay.io/keycloak/keycloak:26.4 in the SDK’s CI, and all of them passed. The iOS and Flutter test drivers run the same tests against the same container. What the tests asserted:
- Sign-in, an authenticated call to a protected origin, UserInfo and sign-out. The token request carried no
client_secret, the ID token was validated before the session becameAuthenticated, and nothing went to a non-protected origin with anAuthorizationheader. - Tampered ID tokens. A re-signed ID token with a wrong
nonce, a bad signature or algorithm, and wrongiss,aud,exp,iatorauth_timewas refused with the matching reason. - Explicit endpoints. The whole flow ran from explicit endpoints, with no request to any
/.well-known/path. - PAR. PAR was used when advertised, a rejected PAR surfaced as
AuthorizationRejectedbefore the browser, and theNeverandRequiredmismatches gaveProviderUnsupported. - Sign-out variants. Sign-out ran with discovery’s
end_session_endpointremoved, with revocation failing, and with the browser dismissed. - No refresh token. A client registered with refresh tokens off and ten-second access tokens ended in
Reauthenticate(SessionExpired)with no token request. - Token scope. The token was attached to the configured origin and nowhere else.
The test realm is a throwaway: development mode, sslRequired: none, a test user.
Sources
Read on 2026-10-30. What the tests observed comes from the SDK’s integration tests, not from these pages.
- Keycloak, securing applications, OIDC endpoints: https://www.keycloak.org/securing-apps/oidc-layers
- Keycloak server admin guide, client basic settings (client authentication, standard flow, valid redirect URIs): https://raw.githubusercontent.com/keycloak/keycloak/main/docs/documentation/server_admin/topics/clients/oidc/con-basic-settings.adoc
- Keycloak server admin guide, offline tokens: https://raw.githubusercontent.com/keycloak/keycloak/main/docs/documentation/server_admin/topics/sessions/offline.adoc
- Keycloak server admin guide, session timeouts and “Revoke Refresh Token”: https://raw.githubusercontent.com/keycloak/keycloak/main/docs/documentation/server_admin/topics/sessions/timeouts.adoc
Next steps
- Wire the client into your app: Android, iOS or Flutter.
- Check what each error category means: Session states and errors.
- See the other provider guide: Ory Hydra.