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:
Approach 1: Auto-consent for first-party clients
Mark your client as "first-party" / skip_consent:
hydra update client your-app --skip-consentWhen 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-exchangeExchange 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_SECRETReturns 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.