Athena, middleware
The Next.js edge middleware that gates every Athena API route
athena/src/middleware.ts is the Next.js edge middleware. Every request to /api/* passes through it before reaching the route handler.
The chain
Request
│
├─ Is it `/api/health` or `/api/auth/**` or `/api/connections/social`?
│ └─→ pass through (public routes)
│
├─ Is it `/api/kratos/**`, `/api/hydra/**`, `/api/iam-kratos/**`, etc.?
│ └─→ pass through (proxy routes, Ory enforces auth)
│
├─ verifySession(cookie)
│ ├─ valid? → continue
│ └─ invalid? → 401 { error: "not_authenticated" }
│
├─ Is it `/api/identities/**` or other admin route?
│ ├─ session.role === "admin"? → continue
│ └─ not admin? → 401 { error: "forbidden" }
│
└─→ Route handler runsWhy edge middleware
Next.js edge middleware runs before the route handler, on the edge runtime (V8 isolates, not Node). Benefits:
- Auth decision happens in milliseconds.
- Common rejection paths don't require loading the full Node route handler.
- Cookie inspection / refresh is centralized.
The edge runtime constraint: no node:crypto, no filesystem. The session HMAC verification uses Web Crypto (crypto.subtle).
Session verification
verifySession(cookie) is the heart:
async function verifySession(cookieValue: string): Promise<Session | null> {
const [payload, signature] = cookieValue.split(".");
const expected = await hmac(payload, process.env.SESSION_SIGNING_KEY!);
if (!constantTimeEquals(signature, expected)) return null;
const session: Session = JSON.parse(atob(payload));
if (session.exp < Date.now() / 1000) return null;
return session;
}- HMAC computed with
SESSION_SIGNING_KEY. constantTimeEqualsavoids timing attacks on signature comparison.- Expiry check (
expis a Unix timestamp in seconds).
Cookie name
athena-session. Path /, HttpOnly, Secure, SameSite=Lax, domain = the domain Athena is served from (CIAM or IAM subdomain).
What's in the session payload
{
"sub": "01H8...",
"role": "admin",
"iat": 1712000000,
"exp": 1712086400
}Just the minimum to enforce auth: identity ID and role. Real identity traits aren't included, those are loaded via the service layer when needed.
Route classification
const PUBLIC_PREFIXES = [
"/api/health",
"/api/auth/",
"/api/connections/social",
];
const PROXY_PREFIXES = [
"/api/kratos/",
"/api/kratos-admin/",
"/api/iam-kratos/",
"/api/iam-kratos-admin/",
"/api/hydra/",
"/api/hydra-admin/",
];
const ADMIN_PREFIXES = [
"/api/identities/",
"/api/sessions/",
"/api/clients/",
"/api/oauth2/",
// ...
];Public routes need no auth. Proxy routes pass through to Ory directly (the proxied Ory APIs enforce their own auth via network ACL or admin headers). Admin routes require both an active session AND role === "admin".
Why proxy routes exist
Sometimes Athena's frontend needs to call Kratos / Hydra directly without going through the service layer. The proxy routes (/api/kratos/**) forward straight to Kratos. The proxied path keeps Athena's domain in the loop, which is sometimes necessary for cookie scoping.
These routes don't get an extra auth layer; the assumption is that the upstream API enforces what's appropriate.
Mutations
Mutation routes (POST, PATCH, DELETE) require Content-Type: application/json. This is implicit CSRF protection, browsers can't make a cross-origin POST with Content-Type: application/json without a preflight (which would fail CORS).
The middleware enforces this for admin routes:
if (isAdminRoute(path) && request.method !== "GET") {
const ct = request.headers.get("Content-Type");
if (!ct?.startsWith("application/json")) {
return Response.json({ error: "bad_content_type" }, { status: 415 });
}
}See Athena API authentication for the full surface.
Edge case: WebSocket upgrade
Athena doesn't currently use WebSockets. If it did, the edge middleware would need to skip the upgrade requests (they don't go through normal route handlers).
Where it logs
Middleware decisions log at INFO level with process.stdout.write({type:"auth_event", ...}). Useful for tracing why a request was rejected. Audit-grade events (lockouts, etc.) are emitted by the SDK, not the middleware.