Olympus Docs
CookbookTokens & OAuth2

Issue access tokens from a session cookie

Bridge Kratos session to OAuth2 access token for your own API

You have a Kratos session (user is signed in to Hera). You need an OAuth2 access token for your API. Without making the user go through OAuth2 again.

Use case

User signs into Hera with email/password → Kratos session cookie.
User opens your-app.com.
your-app.com SPA wants to call your API.
Your API expects OAuth2 access tokens.

Without OAuth2 dance: get a token from the session.

This is "session exchange."

Pattern A: First-party trusted client

Your SPA is registered as a Hydra OAuth2 client. Use the "implicit-like" / "session-to-token" exchange.

But: OAuth2 doesn't have a "session" grant standard. Two approaches:

Mark your client as "first-party" / skip_consent:

hydra update client your-app --skip-consent

When user visits OAuth2 endpoint, Hera (consent app) auto-accepts. From user's view: redirect-redirect-back, instantly authenticated.

// SPA initiates OAuth2 with existing Kratos cookie
window.location.href = `${HYDRA}/oauth2/auth?response_type=code&client_id=${CLIENT_ID}&...`;
// User has Kratos session → Hera auto-accepts → code returned.
// SPA exchanges code → access token.

User never sees consent screen. Functions like a "transparent" exchange.

Approach 2: Custom session exchange endpoint

Build your own endpoint:

// /api/session-to-token
export async function POST(req: Request) {
  const session = await kratos.toSession({ cookie: req.headers.get("cookie") });
  if (!session) return Response.json({ error: "no_session" }, 401);
  
  // Use admin API to mint a token on behalf of user
  const token = await hydraAdmin.createOAuth2Token({
    subject: session.identity.id,
    audience: ["https://your-api"],
    scope: ["openid", "email", "api:read"],
    expires_in: 3600,
  });
  
  return Response.json({ access_token: token.access_token });
}

SPA calls this endpoint with cookie → gets token.

Note: Hydra doesn't have a built-in "mint token from session" endpoint. You'd build this with admin API or via token-exchange grant.

Pattern B: Token Exchange (RFC 8693)

OAuth2 has a standard for this, Token Exchange grant.

hydra create client \
  --name "session-exchange" \
  --grant-types urn:ietf:params:oauth:grant-type:token-exchange

Exchange a session token for access token:

curl -X POST $HYDRA/oauth2/token \
  -d "grant_type=urn:ietf:params:oauth:grant-type:token-exchange" \
  -d "subject_token=$SESSION_TOKEN" \
  -d "subject_token_type=urn:ietf:params:oauth:token-type:session" \
  -d "audience=https://your-api" \
  -u $CLIENT_ID:$CLIENT_SECRET

Returns access_token + ID token (your API can use).

What "first-party" means

Your own apps, owned by you, deserve different treatment than 3rd party OAuth2 clients:

  • First-party: trusted with user data. Skip consent. Long-lived sessions.
  • Third-party: user grants per scope. Limited scopes. Consent required.

Mark via client metadata:

hydra create client \
  --metadata '{"first_party": true}'

Hera's consent flow checks this metadata:

if (consent.client.metadata?.first_party) {
  // Auto-accept
  return hydra.acceptConsent({ ... });
}

Security considerations

CSRF on session-to-token

If the endpoint exchanges cookie for token, it's vulnerable to CSRF. Attacker site CAN'T read response (CORS), but CAN trigger the call.

Mitigation:

  • Require custom header (X-Requested-With).
  • CSRF token.
  • Verify Origin / Referer.
if (req.headers.get("x-requested-with") !== "XMLHttpRequest") {
  return Response.json({ error: "csrf" }, 403);
}

Audience restriction

Issued tokens should be scoped to specific audience:

const token = await hydra.createToken({
  ...,
  audience: ["https://your-api.com"],  // specific
});

Your API verifies aud claim.

Don't expose admin API

The Hydra admin port should NEVER be reachable from your SPA. Always go through your backend:

SPA → Your backend (with cookie) → Hydra Admin API (only your backend can talk to)

Bridge sessions in Hera

Hera implements this for first-party apps:

// Hera's session-bridge endpoint
app.post("/session/bridge", async (req, res) => {
  const session = await kratos.toSession({ cookie: req.headers.cookie });
  if (!session) return res.status(401).json({ error: "no_session" });
  
  // Verify caller is whitelisted (e.g., specific origin)
  const origin = req.headers.origin;
  if (!ALLOWED_BRIDGE_ORIGINS.includes(origin)) {
    return res.status(403);
  }
  
  // Mint token
  const tokens = await hydra.admin.createAuthorizationCode({
    subject: session.identity.id,
    client: BRIDGE_CLIENT_ID,
    ...
  });
  
  res.json({ code: tokens.code });
});

SPA exchanges code → access token via standard OAuth2 token endpoint.

When NOT to do this

If your SPA can do regular OAuth2 (with consent), do that. It's more standard.

Session-exchange is for cases where:

  • Your SPA and Hera are deeply coupled.
  • You don't want users to see consent.
  • You can't redirect them through the OAuth2 flow.

On this page