Skip to main content

Hydra OAuth2 — clients, tokens & keys

OAuth2ClientService, TokenService, JwkService, and TrustedIssuerService wrap Hydra's admin and public surfaces. This page covers the operational workflows you'll actually run in production: registering clients, issuing + introspecting + revoking tokens, and rotating signing keys.

Tenant config

Enable Hydra by adding a hydra block to the tenant config:

IamModule.forRoot({
tenants: {
default: {
mode: 'self-hosted',
transport: 'cookie-or-bearer',
kratos: { publicUrl: '…', adminUrl: '…', adminToken: '…' },
hydra: {
publicUrl: 'http://127.0.0.1:4444',
adminUrl: 'http://127.0.0.1:4445',
clientId: process.env.HYDRA_CLIENT_ID, // for clientCredentials
clientSecret: process.env.HYDRA_CLIENT_SECRET,
},
},
},
});

publicUrl serves token exchange + revoke; adminUrl serves client CRUD, introspection, consent mediation, and JWK CRUD.

Registering an OAuth2 client

import { OAuth2ClientService, type IamOAuth2ClientInput } from 'ory-nestjs';

@Injectable()
export class ProvisioningService {
constructor(private readonly clients: OAuth2ClientService) {}

async registerPartner(name: string) {
const input: IamOAuth2ClientInput = {
clientName: name,
grantTypes: ['client_credentials'],
scope: 'inventory:read inventory:write',
tokenEndpointAuthMethod: 'client_secret_basic',
};
return this.clients.forTenant('default').create(input);
// => { clientId, clientSecret, ... }
// Secret is returned ONCE. Forward to the partner over a secure channel.
}
}

create emits an oauth2.client.create audit event. delete emits oauth2.client.delete.

Patching a client

JSON-Patch lets you flip individual fields without re-sending the whole object:

await this.clients.forTenant('default').patch(clientId, [
{ op: 'replace', path: '/scope', value: 'inventory:read' },
{ op: 'add', path: '/metadata/owner', value: 'team-logistics' },
]);

set(clientId, input) replaces the whole client (use with care — missing fields reset to defaults).

Issuing tokens

client_credentials (M2M)

const token = await this.tokens.forTenant('default').clientCredentials([
'inventory:read',
]);
// { accessToken, tokenType: 'Bearer', expiresIn: 3600, scope: ['inventory:read'] }

Uses the tenant's configured hydra.clientId / hydra.clientSecret.

authorization_code + PKCE

const token = await this.tokens.forTenant('default').authorizationCode({
code: req.query.code,
redirectUri: 'https://app.example.com/callback',
clientId: 'public-spa',
codeVerifier: req.session.pkceVerifier, // public client — no clientSecret
});

refresh_token

const fresh = await this.tokens.forTenant('default').refresh({
refreshToken: oldToken.refreshToken,
clientId: 'mobile-app',
clientSecret: '…',
scope: ['offline_access', 'inventory:read'],
});

jwt-bearer (federated / server-signed assertions)

const token = await this.tokens.forTenant('default').jwtBearer({
assertion: signedJwt,
scope: ['inventory:read'],
});

The issuer of assertion must be registered via TrustedIssuerService first (see below).

Introspecting & revoking

const info = await this.tokens.forTenant('default').introspect(token);
if (!info.active) throw new UnauthorizedException();

await this.tokens.forTenant('default').revoke(token, {
tokenTypeHint: 'refresh_token',
clientId: 'mobile-app',
clientSecret: '…',
});

introspect returns active: false for unknown/expired tokens — it does not throw.

JWK management

Hydra signs ID tokens and (optionally) access tokens with keys in its JWK store. Rotate with JwkService:

// Generate a new signing key set
await this.jwks.forTenant('default').createSet('my-signing-set', {
alg: 'RS256',
use: 'sig',
});

// List keys later
const set = await this.jwks.forTenant('default').getSet('my-signing-set');

// Delete an old set
await this.jwks.forTenant('default').deleteSet('old-set');

For rolling rotations, keep N and N+1 coexisting in the same set, then delete N once caches expire.

Trusted JWT-bearer issuers

Before accepting jwtBearer grants, register the issuer:

await this.trustedIssuers.forTenant('default').trust({
issuer: 'https://partner.example.com',
subject: 'partner-service-account',
scope: ['inventory:read'],
expiresAt: '2027-01-01T00:00:00Z',
publicKey: { kty: 'RSA', kid: 'partner-2026', n: '…', e: 'AQAB' },
});

Use allowAnySubject: true to accept any sub claim from the issuer (useful when the issuer brokers many users).

Diagnostics

const { version } = await this.metadata.forTenant('default').version();
const jwks = await this.metadata.forTenant('default').discoverJwks();

discoverJwks hits Hydra's public /.well-known/jwks.json — what clients use to verify ID tokens.