Athena API Authentication
Session model, role checks, and route classification for the Athena admin API
Overview
Every Athena API route is protected by Next.js edge middleware (src/middleware.ts). The middleware verifies an HMAC-signed session cookie on each request before it reaches any route handler. Routes without a valid session receive a 401. Routes with a valid session but insufficient role receive a 403. Only /api/health and /api/auth/** are public.
This document covers how to obtain a session, which routes require which roles, and what error responses to expect.
Audience: Internal developers integrating with or debugging the Athena admin API.
How Authentication Works
Athena uses a single authentication mechanism: the athena-session cookie, set after a successful OAuth2 login via the Olympus IAM OAuth2 server.
Request arrives at Athena
│
▼
middleware.ts (Next.js edge middleware)
│
├─ isPublicRoute()? ──Yes──→ Pass through (no auth required)
│ /api/health
│ /api/auth/**
│ /api/connections/social (read-only, fetched by Hera)
│
├─ isProxyRoute()? ──Yes──→ Pass through (Olympus identity APIs enforce their own auth)
│ /api/kratos/**
│ /api/kratos-admin/**
│ /api/iam-kratos/**
│ /api/iam-kratos-admin/**
│ /api/hydra/**
│ /api/hydra-admin/**
│
▼
verifySession(cookie) ──Fail──→ 401 { "error": "not_authenticated", "message": "...", "hint": "..." }
│
▼
isAdminRoute()? ──Yes──→ role === "admin"? ──No──→ 403 { "error": "forbidden", "message": "...", "hint": "..." }
│
▼
Route handler runsThe session cookie is an HMAC-SHA256-signed JSON payload. Any tampered or missing cookie fails verifySession() and returns 401 before the route handler runs.
Obtaining a Session
Athena uses the OAuth2 Authorization Code + PKCE flow through the Olympus IAM OAuth2 server. There is no API key, bearer token, or server-to-server auth path, only browser-initiated OAuth2 sessions are supported. See athena#50 for the planned M2M credential flow for service-to-service use cases.
Full Login Flow
Developer browser
→ GET /api/auth/login
(Athena generates: state, code_verifier, code_challenge=SHA-256(verifier))
(Stores state and code_verifier in short-lived HttpOnly cookies)
→ Redirect to Olympus IAM OAuth2 server /oauth2/auth
?client_id=...
&response_type=code
&scope=openid profile email
&redirect_uri=http://localhost:4001/api/auth/callback
&state=<random>
&code_challenge=<base64url(SHA-256(verifier))>
&code_challenge_method=S256
→ Olympus IAM login UI (user authenticates)
→ Olympus IAM OAuth2 server redirects back to /api/auth/callback?code=...&state=...
→ Athena validates state, retrieves code_verifier from cookie
→ Athena POSTs to /oauth2/token
grant_type=authorization_code
code=<code>
redirect_uri=...
client_id=...
code_verifier=<verifier> ← no client_secret (public client)
→ Athena fetches verified identity claims from /oauth2/userinfo
→ Athena sets athena-session cookie (HttpOnly, SameSite=Strict, Secure in production)
→ Browser redirects to /dashboardStep 1: Initiate Login
GET http://localhost:4001/api/auth/loginThis redirects to the Olympus IAM OAuth2 authorization endpoint with a PKCE code_challenge and code_challenge_method=S256. The browser follows the redirect to the Olympus IAM login page.
Step 2: Complete Authentication
Log in at the Olympus IAM login page. After successful authentication, the OAuth2 server redirects back to Athena's callback route (/api/auth/callback).
Step 3: Session Cookie Set
On callback success, Athena sets the athena-session cookie:
| Attribute | Value |
|---|---|
| Name | athena-session |
HttpOnly | true |
SameSite | strict |
Secure | true (production only; false on localhost to allow HTTP in dev) |
| Lifetime | Matches OAuth2 token expires_in, capped at 8 hours (28800 seconds) |
Step 4: Use the Session
# Example: list all settings with an admin session
curl -b "athena-session=<cookie-value>" http://localhost:4001/api/settings
# Example: get a single setting
curl -b "athena-session=<cookie-value>" http://localhost:4001/api/settings/captcha.enabled
# Example: write a setting
curl -b "athena-session=<cookie-value>" \
-X POST \
-H "Content-Type: application/json" \
-d '{"key":"captcha.enabled","value":"true"}' \
http://localhost:4001/api/settingsNote: The session cookie is
HttpOnly. You cannot read its value from JavaScript, that is intentional. In a browser, the cookie is sent automatically on every same-origin request. Forcurltesting, copy the cookie value from your browser's DevTools (Application > Cookies >athena-session).
Session Check Lifecycle (AuthProvider)
When a user opens Athena in a browser, the AuthProvider component runs a session check before rendering any content. This produces a 401 in the browser console on every unauthenticated page load. This is expected behavior, not an error.
Flow
Browser loads Athena (any route)
│
▼
AuthProvider mounts
│
▼
fetch("GET /api/auth/session")
│
├─ 200 + session data → isAuthenticated=true → render dashboard
│
└─ 401 { "error": "Not authenticated" }
│
▼
isAuthenticated=false
│
▼
window.location.href = "/api/auth/login"
│
▼
OAuth2 flow starts (Hydra → Hera login form)Why the Console Shows a 401
The AuthProvider (src/providers/AuthProvider.tsx) calls GET /api/auth/session on mount via the useAuth Zustand store. When no athena-session cookie exists (first visit, expired session, after logout), the endpoint returns 401. The AuthProvider then redirects the browser to /api/auth/login to start the OAuth2 flow.
This 401 appears as a red error line in the browser console:
GET http://localhost:3001/api/auth/session 401 (Unauthorized)This is expected. The 401 is the mechanism by which Athena detects an unauthenticated user. It is not a bug. After login completes, the athena-session cookie is set and subsequent calls to /api/auth/session return 200.
After Successful Login
fetch("GET /api/auth/session")
→ 200 { "user": { "kratosIdentityId": "...", "email": "admin@demo.user", "role": "admin" } }
→ AuthProvider sets isAuthenticated=true
→ Dashboard rendersThe session check runs once on mount. The hasCheckedSession ref prevents duplicate calls. No polling occurs, if the session expires mid-use, API calls return 401 and the next page navigation triggers the auth redirect.
Dual-Domain Session Isolation
CIAM Athena (:3001) and IAM Athena (:4001) each have their own athena-session cookie scoped to their respective origin. Logging into one does not grant access to the other. Each domain runs its own OAuth2 flow against its respective Hydra instance.
No Server-to-Server Auth Path
Athena has no server-to-server authentication path. There is no API key, bearer token, or client_credentials flow for Athena.
Why: Athena is an admin dashboard UI, not an API service. It is not designed to be called by other services. The authentication model, browser-initiated OAuth2 sessions, is intentional.
If you need service-to-service access: Use the Olympus IAM OAuth2 server's client_credentials flow directly (see athena#50 for the planned M2M capability). Do not attempt to build an alternative auth path into Athena.
Route Authentication Requirements
| Route | Method | Auth Required | Role Required |
|---|---|---|---|
/api/health | GET | None (public) | - |
/api/auth/** | ANY | None (public) | - |
/api/settings | GET | Session | admin |
/api/settings | POST | Session | admin |
/api/settings/:key | GET | Session | admin |
/api/settings/:key | DELETE | Session | admin |
/api/encrypt | POST | Session | admin |
/api/config | GET | Session | admin |
/api/security/** | ANY | Session | admin |
/api/identities | GET | Session | admin |
/api/connections/social | GET | None (read-only provider list) | - |
/api/dashboard/layout | GET, PUT | Session | any authenticated |
/api/session-locations | GET | Session | any authenticated |
/api/geo | GET | Session | any authenticated |
/api/services/** | ANY | Session | any authenticated |
/api/kratos/** | ANY | Proxy (Olympus identity APIs enforce auth) | - |
/api/kratos-admin/** | ANY | Proxy (Olympus identity APIs enforce auth) | - |
/api/iam-kratos/** | ANY | Proxy (Olympus identity APIs enforce auth) | - |
/api/iam-kratos-admin/** | ANY | Proxy (Olympus identity APIs enforce auth) | - |
/api/hydra/** | ANY | Proxy (Olympus identity APIs enforce auth) | - |
/api/hydra-admin/** | ANY | Proxy (Olympus identity APIs enforce auth) | - |
Roles:
admin, full access to all protected routesviewer, can access dashboard, session-locations, and geo routes; cannot access settings, encrypt, config, or security routes
Role is determined at login from the identity's traits.role field in the Olympus identity store and embedded in the session cookie. It cannot be changed without re-logging in.
Error Responses
All Athena API authentication errors return a consistent JSON shape. This applies to middleware-enforced 401 and 403 responses. Error codes are stable, callers can rely on the error field value without parsing the message field.
{
"error": "<machine_code>",
"message": "<human readable explanation>",
"hint": "<actionable next step>"
}The Content-Type header on all auth error responses is application/json. No HTML error pages are returned on auth failures.
What never appears in error responses: PII (email addresses, session tokens, identity IDs), internal service names (Kratos, Hydra), internal hostnames, ports, or stack traces.
401, Not Authenticated
Returned when the athena-session cookie is absent, expired, or fails HMAC verification.
{
"error": "not_authenticated",
"message": "Authentication required.",
"hint": "Authenticate via /api/auth/login"
}Error code: not_authenticated, stable, machine-readable, will not change without a versioning event.
Common causes:
- Cookie not sent, check that
curl -bincludes the cookie or that the browser has the cookie for the correct domain - Cookie expired, the session lifetime matches the OAuth2 token
expires_in; re-authenticate - Cookie tampered, any modification to the cookie value invalidates the HMAC signature
Remediation: Obtain a session via GET /api/auth/login and include the resulting athena-session cookie on subsequent requests.
403, Forbidden
Returned when the session is valid but the identity's role is not sufficient for the route.
{
"error": "forbidden",
"message": "Admin access required.",
"hint": "Contact your administrator to request access."
}Error code: forbidden, stable, machine-readable, will not change without a versioning event.
Remediation: Log in with an identity that has the admin role assigned in the Olympus identity store. The role is embedded in the session at login time, changing the role in the admin panel requires a new login to take effect.
Canonical Error Code Reference
| HTTP Status | error field | When |
|---|---|---|
401 | not_authenticated | No session, expired session, or tampered cookie |
403 | forbidden | Valid session but insufficient role for this route |
These codes are the stable contract for this API. Parse the error field, not the message field, in any programmatic error handling.
Cookie Security
The athena-session cookie is protected by four security attributes that are non-negotiable:
| Attribute | Value | Why It Matters |
|---|---|---|
HttpOnly | true | Prevents JavaScript from reading the cookie. XSS attacks cannot exfiltrate the session. |
SameSite=Strict | strict | Blocks the cookie from being sent on any cross-origin request, including top-level navigations. Stronger CSRF protection than Lax, with no UX impact on an admin-only dashboard. |
Secure | true (production), false (localhost dev) | Cookie is only sent over HTTPS in production. Prevents interception on the wire. The flag is false in local development to allow HTTP on localhost. |
MaxAge | min(expires_in, 28800) | Admin sessions are capped at 8 hours regardless of what the OAuth2 server returns in expires_in. Prevents multi-day admin sessions. |
Cookie attributes are centralized in src/lib/cookie-options.ts. All cookie write and delete operations in the codebase import from this single source of truth, future changes require updating only one file. A CI step enforces this requirement: any cookies.set("athena-session", ...) call with an inline options object outside this helper fails the build. See Athena Testing Guide, Cookie Audit for the full requirement for new routes.
Do not disable or weaken these attributes. Do not attempt to read the session cookie from document.cookie, HttpOnly means it is not accessible from JavaScript, and this is intentional.
GDPR note: The
athena-sessioncookie is tied to a specific Olympus identity (kratosIdentityId). If a user exercises their right to erasure, their identity record must be deleted from the Olympus identity store, this automatically invalidates all active sessions for that identity.
Proxy Route Trust Model
Routes under /api/kratos/**, /api/kratos-admin/**, /api/iam-kratos/**, /api/iam-kratos-admin/**, /api/hydra/**, and /api/hydra-admin/** are reverse proxies to Olympus identity service APIs. These routes bypass Athena session auth by design.
How they are secured instead:
- Athena decrypts the Olympus identity service API key from the settings store and injects it as an
Authorizationheader before forwarding the request. - The Olympus identity service verifies the API key server-side on every proxied request.
- Security relies on network isolation: port 4001 (IAM Athena) must not be publicly reachable. The admin APIs are accessible only within the container network.
Known gap: Proxy routes are currently accessible to any authenticated user, not admin-only. The Olympus identity service enforces API key auth upstream. This will be addressed in a follow-on issue.
Edge Cases
Missing SESSION_SIGNING_KEY Environment Variable
If SESSION_SIGNING_KEY is not set in the container, Athena fails to start with a fatal error. See session-signing-key.md for the full startup validation behavior and fix command. Verify with GET /api/health, if it returns 200, the environment is configured correctly.
Note: ENCRYPTION_KEY is a separate key used for SDK settings encryption. Missing ENCRYPTION_KEY affects settings decryption, not session authentication. See session-signing-key.md for key separation details.
/api/healthcheck vs /api/health
Only /api/health is the public health endpoint. /api/healthcheck is not a recognized public route and returns 401 without a session. Use /api/health for ops tooling and liveness probes.
Header Injection Has No Effect
The middleware reads the user role exclusively from the HMAC-signed session cookie. Sending x-user-role: admin or any other role header has no effect. Role cannot be escalated via request headers.
Path Traversal
Next.js normalizes request paths before matcher evaluation. Path traversal attempts such as GET /api/../api/settings resolve to GET /api/settings and are subject to normal auth enforcement.
OAuth2 State Mismatch
If the oauth_state cookie is missing or does not match the state query parameter on callback, Athena redirects back to /api/auth/login without setting a session. This prevents CSRF attacks on the OAuth2 flow. Re-initiate login to generate a fresh state.
Security Considerations
- Session tampering: The
athena-sessioncookie is HMAC-SHA256-signed withSESSION_SIGNING_KEY(notENCRYPTION_KEY, these are separate keys as of athena#99). Any modification to the cookie payload invalidates the signature and returns401. See session-signing-key.md for key configuration and rotation. - Cookie security: In production, the cookie is
HttpOnlyandSecure. Do not disable these attributes. See Cookie Security above. - No fallback paths: There is no alternative authentication path (API key, bearer token, basic auth). There is no secret endpoint. If the middleware is bypassed, only
/api/dashboard/layouthas an inlineverifySession()call as a secondary layer, all other routes would be unprotected. - Secret decryption route:
GET /api/settings/:key?decrypt=truereturns the plaintext value of an AES-256-GCM encrypted secret. This route requires an admin session. See athena#51 for the security fix that enforced this. - Port exposure: Port 4001 (IAM Athena) must not be internet-accessible. The proxy routes to Olympus identity service admin APIs are authenticated by API keys, not by Athena session auth. A publicly reachable port 4001 allows any actor with network access to use the admin proxy.
- PKCE enforcement: The OAuth2 login flow uses
code_challenge_method=S256. The plain method is not used. See athena#52 for PKCE enforcement details.
Related Issues
- athena#50, Planned M2M credential flow (server-to-server auth)
- athena#51, Security fix:
proxy.ts→middleware.ts(auth enforcement was dead code) - athena#52, PKCE S256 enforcement and JWKS verification
- athena#57, Security fix: session cookie
Secureflag,SameSite=Strict, andmaxAgecap - athena#60, Standardized 401/403 error response shape (
not_authenticated/forbidden) - athena#61, This document
- athena#63, PKCE + public client OAuth2 integration guide
- athena#99, SESSION_SIGNING_KEY separation from ENCRYPTION_KEY (see session-signing-key.md)
- athena#66, CI enforcement:
audit:cookiesstep forcookie-options.tsusage
Last updated: 2026-04-08 (Technical Writer, corrected error response field name from suggestion to hint to match middleware source, updated 401/403 message text to match implementation)