Olympus Docs
CookbookTokens & OAuth2

Validate an access token (Node.js)

Verify Olympus access tokens in a Node.js backend

This recipe shows how to validate an access token issued by Olympus Hydra in a Node.js backend service.

There are two paths depending on whether you've configured Hydra to issue opaque tokens (the Olympus default) or JWT tokens.

Path 1, Opaque tokens (default)

Opaque tokens are random strings that must be introspected at Hydra's /oauth2/introspect endpoint.

Install

bun add ofetch

Implement

// src/lib/auth.ts
import { ofetch } from "ofetch";

const HYDRA_INTROSPECT = process.env.HYDRA_INTROSPECT_URL!;
// e.g. https://ciam.your-domain/.ory/hydra/admin/oauth2/introspect
// (NOTE: introspect is on the admin API, which must be firewalled, see Operate, Network Topology)

const HYDRA_ADMIN_AUTH = `Basic ${Buffer.from(
  `${process.env.HYDRA_ADMIN_USER}:${process.env.HYDRA_ADMIN_PASS}`,
).toString("base64")}`;

export interface TokenInfo {
  active: boolean;
  sub?: string;
  scope?: string;
  client_id?: string;
  exp?: number;
  iat?: number;
  iss?: string;
  aud?: string[];
  // any custom claims you've configured
  ext?: Record<string, unknown>;
}

export async function validateToken(token: string): Promise<TokenInfo> {
  const res = await ofetch<TokenInfo>(HYDRA_INTROSPECT, {
    method: "POST",
    headers: {
      "content-type": "application/x-www-form-urlencoded",
      authorization: HYDRA_ADMIN_AUTH,
    },
    body: new URLSearchParams({ token }).toString(),
  });
  if (!res.active) {
    throw new Error("Token inactive (expired, revoked, or unknown)");
  }
  return res;
}

Wire into your handler

// src/app/api/widgets/route.ts (Next.js)
import { validateToken } from "@/lib/auth";

export async function GET(request: Request) {
  const auth = request.headers.get("authorization");
  if (!auth?.startsWith("Bearer ")) {
    return Response.json({ error: "missing_token" }, { status: 401 });
  }

  try {
    const info = await validateToken(auth.slice(7));
    if (!info.scope?.split(" ").includes("read:widgets")) {
      return Response.json({ error: "insufficient_scope" }, { status: 403 });
    }
    return Response.json({ widgets: [], user: info.sub });
  } catch (e) {
    return Response.json({ error: "invalid_token" }, { status: 401 });
  }
}

Note: introspection cost

Each introspection is one HTTP round-trip to Hydra. If your service handles >1000 RPS, cache introspection results for short windows (30s) keyed by token hash. Be careful with revocation latency: cached "active" tokens stay accepted until the cache window expires.

Path 2, JWT tokens (configured per client)

If you've registered the OAuth2 client with access_token_strategy=jwt, the access token is a JWT signed by Hydra. Validate locally with the JWKS.

Install

bun add jose

Implement

// src/lib/auth-jwt.ts
import { createRemoteJWKSet, jwtVerify, type JWTPayload } from "jose";

const ISSUER = process.env.OLYMPUS_ISSUER!;
// e.g. https://ciam.your-domain

const JWKS = createRemoteJWKSet(
  new URL(`${ISSUER}/.well-known/jwks.json`),
  { cacheMaxAge: 10 * 60 * 1000 }, // 10 min
);

export interface TokenClaims extends JWTPayload {
  scope?: string;
  client_id?: string;
}

export async function validateJwt(token: string): Promise<TokenClaims> {
  const { payload } = await jwtVerify(token, JWKS, {
    issuer: ISSUER,
    // audience: optionally restrict to your client_id
  });
  return payload as TokenClaims;
}

Wire it in

Same shape as the opaque example, but call validateJwt instead of validateToken. No network round-trip on the hot path (after the initial JWKS fetch is cached).

Note: JWT revocation

JWT access tokens are stateless. They cannot be revoked before their expiry. If you need revocation on demand (logout-and-forget), use opaque tokens or accept the revocation latency = access-token lifetime (default 1 hour).

Choosing between paths

Use caseRecommendation
Backend can tolerate ~30s latency to revoke a tokenOpaque (default)
Service handles >1000 RPS and revocation latency of 1h is acceptableJWT
Multi-region service where introspecting cross-region is too slowJWT
Per-user logout must take effect immediatelyOpaque (must)

On this page