Ory Hydra — Versola Docs
/docs
0.1.0versola.ioGitHub

Ory Hydra

Connect the Versola OAuth SDK to self-hosted Ory Hydra v26.2.0 with a public client, with server and client settings and what the SDK's tests observed.

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.issuer is announced verbatim, trailing slash included, in discovery and in every ID token’s iss. Hydra builds its issuer and every URL it hands the browser from urls.self.issuer and urls.self.public, never from the request. Decide the value once and give the SDK the same string.
  • login, consent and logout are the URLs of your user-facing application.
  • oauth2.pkce.enforced_for_public_clients: true makes Hydra refuse a public client’s request without a code challenge. The SDK always sends S256, 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: none is what makes the client public. Hydra’s CLI default is client_secret_basic, so set none explicitly.
  • redirect_uris are matched exactly. Wildcards are not allowed, custom schemes such as com.example.app:/callback are allowed, and javascript:, data: and vbscript: are rejected. Register the exact redirectUri string of each platform. This covers Android App Links, iOS Universal Links and custom schemes alike.
  • post_logout_redirect_uris must contain the exact postLogoutRedirectUri. Only registered URIs can be used.
  • scope lists every scope the client may request, including offline_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: include offline_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 at create().
  • postLogoutRedirectUri: set it, and register it. sessionEnded is true only when the browser returns to it with the state the SDK sent.
  • endSessionEndpoint: not needed. Discovery advertises it.
  • pushedAuthorization: leave the default. Hydra advertises no PAR, so WhenAdvertised sends the plain request, and Required would be ProviderUnsupported("par").
  • tokenBinding: leave Bearer. Hydra advertises no DPoP, so requiring DPoP is ProviderUnsupported("dpop"), and asking for DPoP without requiring it falls back to bearer with a warning.

What to expect

CapabilityHydra v26.2.0What the SDK does
Discoveryissuer verbatim from urls.self.issuer. jwks_uri under /.well-known/jwks.jsonFetches {issuer}/.well-known/openid-configuration and compares issuers.
Refresh tokenOnly with offline_access requested and grantedWith it: Authenticated with a refresh token. Without: the session ends at access-token expiry with Reauthenticate(SessionExpired), no token request.
RevocationPresent. A public client sends client_id onlytokenRevoked = true on success.
End-sessionPresent, through your logout UIsessionEnded = true when the browser returns with state.
UserInfoPresent. Opaque access tokens are accepteduserInfo() works.
PARNot advertisedPlain authorization request.
RFC 9207 iss on the redirectNot advertised and not sentNot required because not advertised. Checked when present.
DPoPNot advertisedBearer.
ID-token algorithmRS256 onlyAccepted.
Date header on the token responseAbsentNo server-time skew correction. The device clock is checked within clockSkewTolerance.
Rate limitsSelf-hosted: none in the configuration. Ory Network: 429 with x-ratelimit-reset, no Retry-After mentionedOrdinary 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 iss on the redirect means the SDK can’t use RFC 9207 mix-up protection against Hydra. The state check and the ID token’s iss still 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 no client_secret anywhere.
  • 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 and Required-without-endpoint cases gave ProviderUnsupported("par").
  • Sign-out variants with end_session_endpoint removed, with revocation failing, and dismissed.
  • No refresh token. A client with grant type authorization_code only, a scope without offline_access and an access-token lifespan of 10 s ended in Reauthenticate(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.

Next steps