You can sign users in with Ory Hydra through the Versola OAuth SDK’s OAuthClient by registering a public client and passing Hydra’s issuer to the SDK. This guide is backed by the SDK’s own integration tests, which ran against oryd/hydra:v26.2.0 with Ory’s reference login-consent app oryd/hydra-login-consent-node:v26.2.0.
Read Provider guides first for the minimum contract and the redirect setup that is the same for every provider.
Hydra is an OAuth 2.0 and OpenID Connect server without a user interface. Sign-in, consent and sign-out screens are an application you write or deploy and point Hydra at. Everything the user sees during login() and logout() comes from that application, not from the SDK or Hydra.
What to configure in Hydra
Server configuration
These keys come from the hydra.yml of the test setup. Values are placeholders.
urls:
self:
issuer: https://auth.example.com/
public: https://auth.example.com/
login: https://login.example.com/login
consent: https://login.example.com/consent
logout: https://login.example.com/logout
secrets:
system:
- replace-with-your-own-generated-secret
oauth2:
pkce:
enforced_for_public_clients: true
urls.self.issueris announced verbatim, trailing slash included, in discovery and in every ID token’siss. Hydra builds its issuer and every URL it hands the browser fromurls.self.issuerandurls.self.public, never from the request. Decide the value once and give the SDK the same string.login,consentandlogoutare the URLs of your user-facing application.oauth2.pkce.enforced_for_public_clients: truemakes Hydra refuse a public client’s request without a code challenge. The SDK always sendsS256, so it doesn’t depend on this. Enable it so other clients can’t skip PKCE. Ory’s documentation says PKCE can be used without enforcement by default, and can be required for all clients or only for public ones.
The client
Register the app as a public client. The test setup imports an array of clients with the Hydra CLI.
hydra import oauth2-client clients.json --endpoint http://hydra.example.com:4445
A single object is refused. The file is a JSON array.
[
{
"client_id": "example-app",
"token_endpoint_auth_method": "none",
"grant_types": ["authorization_code", "refresh_token"],
"response_types": ["code"],
"scope": "openid offline_access profile email",
"redirect_uris": ["https://app.example.com/oauth"],
"post_logout_redirect_uris": ["https://app.example.com/signed-out"],
"skip_consent": false
}
]
token_endpoint_auth_method: noneis what makes the client public. Hydra’s CLI default isclient_secret_basic, so setnoneexplicitly.redirect_urisare matched exactly. Wildcards are not allowed, custom schemes such ascom.example.app:/callbackare allowed, andjavascript:,data:andvbscript:are rejected. Register the exactredirectUristring of each platform. This covers Android App Links, iOS Universal Links and custom schemes alike.post_logout_redirect_urismust contain the exactpostLogoutRedirectUri. Only registered URIs can be used.scopelists every scope the client may request, includingoffline_access. A client whose scope lacks it never receives a refresh token.
Scopes and refresh tokens
A refresh token is issued when offline_access is requested and granted. The client must be allowed to request it, and your consent application must accept it for the user. The tests observed a token for openid profile offline_access and none for openid profile. Discovery lists scopes_supported: [offline_access, offline, openid]. The test consent app grants every scope the request asks for. Yours must not drop offline_access.
Refresh token rotation
Hydra’s refresh tokens are always single-use, and reuse revokes the tokens linked to the same consent. The optional grace period is oauth2.grant.refresh_token.rotation_grace_period. It is off unless set, and at most 5 minutes without a reuse count. It softens a retry after a crash. The Ory page calls it a workaround that weakens reuse detection, and the SDK’s rule doesn’t change with it: invalid_grant is Reauthenticate(GrantRejected), never retried.
Sign-out
Hydra’s end-session endpoint is {public}/oauth2/sessions/logout. With an id_token_hint, a registered post_logout_redirect_uri and state it redirects the browser to your logout UI (urls.logout, with a logout_challenge), which must accept or reject the request. Then Hydra redirects to the post-logout URI carrying state. The test app confirms the logout unless the user declines. If your logout UI never accepts the request, the browser never returns and logout() reports sessionEnded = false.
The revocation endpoint is /oauth2/revoke. A public client sends client_id with token and no other client authentication. The revoked refresh token is then 400 invalid_grant at the token endpoint.
SDK configuration
issuer is exactly what urls.self.issuer is, trailing slash included. The SDK accepts either form at the discovery comparison (one trailing slash is ignored there), but the ID token’s iss is held to the discovered value byte for byte, so write what Hydra announces.
Kotlin
import versola.oauth.OAuthClient
import versola.oauth.OAuthConfiguration
val client = OAuthClient.create(
context,
OAuthConfiguration(
issuer = "https://auth.example.com/",
clientId = "example-app",
redirectUri = "https://app.example.com/oauth",
postLogoutRedirectUri = "https://app.example.com/signed-out",
scopes = setOf("openid", "profile", "offline_access"),
),
)
Swift
import OAuthClient
let configuration = try OAuthConfiguration(
issuer: "https://auth.example.com/",
clientId: "example-app",
redirectUri: "https://app.example.com/oauth",
postLogoutRedirectUri: "https://app.example.com/signed-out",
scopes: ["openid", "profile", "offline_access"]
)
let client = try await OAuthClient.create(
configuration: configuration,
presentationAnchor: appKeyWindow // from the Provider guides page
)
Dart
import 'package:versola_oauth/versola_oauth.dart';
final client = await OAuthClient.create(const OAuthConfiguration(
issuer: 'https://auth.example.com/',
clientId: 'example-app',
redirectUri: 'https://app.example.com/oauth',
postLogoutRedirectUri: 'https://app.example.com/signed-out',
scopes: {'openid', 'profile', 'offline_access'},
));
Notes on the values
scopes: includeoffline_access, or the session has no refresh token and ends when the access token expires. The SDK never adds it. Discovery lists it, so a configuration without it gets one warning atcreate().postLogoutRedirectUri: set it, and register it.sessionEndedistrueonly when the browser returns to it with thestatethe SDK sent.endSessionEndpoint: not needed. Discovery advertises it.pushedAuthorization: leave the default. Hydra advertises no PAR, soWhenAdvertisedsends the plain request, andRequiredwould beProviderUnsupported("par").tokenBinding: leaveBearer. Hydra advertises no DPoP, so requiring DPoP isProviderUnsupported("dpop"), and asking for DPoP without requiring it falls back to bearer with a warning.
What to expect
| Capability | Hydra v26.2.0 | What the SDK does |
|---|---|---|
| Discovery | issuer verbatim from urls.self.issuer. jwks_uri under /.well-known/jwks.json | Fetches {issuer}/.well-known/openid-configuration and compares issuers. |
| Refresh token | Only with offline_access requested and granted | With it: Authenticated with a refresh token. Without: the session ends at access-token expiry with Reauthenticate(SessionExpired), no token request. |
| Revocation | Present. A public client sends client_id only | tokenRevoked = true on success. |
| End-session | Present, through your logout UI | sessionEnded = true when the browser returns with state. |
| UserInfo | Present. Opaque access tokens are accepted | userInfo() works. |
| PAR | Not advertised | Plain authorization request. |
RFC 9207 iss on the redirect | Not advertised and not sent | Not required because not advertised. Checked when present. |
| DPoP | Not advertised | Bearer. |
| ID-token algorithm | RS256 only | Accepted. |
Date header on the token response | Absent | No server-time skew correction. The device clock is checked within clockSkewTolerance. |
| Rate limits | Self-hosted: none in the configuration. Ory Network: 429 with x-ratelimit-reset, no Retry-After mentioned | Ordinary backoff, because the SDK doesn’t read x-ratelimit-reset. A 429 or 503 with Retry-After is honoured (Server errors, 429 and Retry-After) |
Known limits
- You need a login, consent and logout application. The SDK can’t supply one.
- No
isson the redirect means the SDK can’t use RFC 9207 mix-up protection against Hydra. Thestatecheck and the ID token’sissstill apply. - A refresh replay after a crash ends in
Reauthenticate(GrantRejected). The grace period softens it.
How this was checked
The Android integration tests ran against oryd/hydra:v26.2.0 in the SDK’s CI. The PAR push tests don’t apply to Hydra, because it advertises no PAR, and the two ProviderUnsupported tests for PAR run. The iOS and Flutter test drivers run the same tests. What ran:
- Sign-in with
openid profile offline_access, an authenticated call, UserInfo, revocation and sign-out through the logout UI, with noclient_secretanywhere. - Tampered ID tokens refused with the matching reason.
- Explicit endpoints. The whole flow ran from explicit endpoints, with no discovery request.
- PAR mismatches. The
Never-with-required-PAR andRequired-without-endpoint cases gaveProviderUnsupported("par"). - Sign-out variants with
end_session_endpointremoved, with revocation failing, and dismissed. - No refresh token. A client with grant type
authorization_codeonly, a scope withoutoffline_accessand an access-token lifespan of 10 s ended inReauthenticate(SessionExpired). - Token scope. The token was attached to the configured origin only.
The stack was DSN=memory with serve all --dev (plain HTTP, non-secure cookies). The SDK reached it through the test driver’s proxy on a fixed port, because Hydra takes its URLs from configuration. The login conversation was about ten HTTP hops through Ory’s reference app.
Sources
Read on 2026-10-30. What the tests observed comes from the SDK’s integration tests, not from these pages.
- Ory, OAuth2 clients (public client, redirect URIs, post-logout URIs, grant types): https://www.ory.com/docs/hydra/guides/oauth2-clients
- Ory, OIDC logout (
id_token_hint,post_logout_redirect_uri,state, registered URIs): https://www.ory.com/docs/oauth2-oidc/oidc-logout - Ory, authorization code flow (PKCE enforcement,
offline_access, consent, S256): https://www.ory.com/docs/oauth2-oidc/authorization-code-flow - Ory, graceful token refresh (rotation grace period): https://www.ory.com/docs/hydra/guides/graceful-token-refresh
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: Keycloak.