Dashboard API Authentication
Session model, role checks, and route classification for the Dashboard admin API
Overview
Every Dashboard 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 Dashboard admin API.
How Authentication Works
Dashboard uses a single authentication mechanism: the dashboard-session cookie, set after a successful OAuth2 login via the Ionize IAM OAuth2 server.
Request arrives at Dashboard
│
▼
middleware.ts (Next.js edge middleware)
│
├─ isPublicRoute()? ──Yes──→ Pass through (no auth required)
│ /api/health
│ /api/auth/**
│ /api/connections/social (read-only, fetched by Auth)
│
├─ isProxyRoute()? ──Yes──→ Pass through (Ionize 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
Dashboard uses the OAuth2 Authorization Code + PKCE flow through the Ionize IAM OAuth2 server. There is no API key, bearer token, or server-to-server auth path, only browser-initiated OAuth2 sessions are supported. See orbit#50 for the planned M2M credential flow for service-to-service use cases.
Full Login Flow
Developer browser
→ GET /api/auth/login
(Dashboard generates: state, code_verifier, code_challenge=SHA-256(verifier))
(Stores state and code_verifier in short-lived HttpOnly cookies)
→ Redirect to Ionize 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
→ Ionize IAM login UI (user authenticates)
→ Ionize IAM OAuth2 server redirects back to /api/auth/callback?code=...&state=...
→ Dashboard validates state, retrieves code_verifier from cookie
→ Dashboard POSTs to /oauth2/token
grant_type=authorization_code
code=<code>
redirect_uri=...
client_id=...
code_verifier=<verifier> ← no client_secret (public client)
→ Dashboard fetches verified identity claims from /oauth2/userinfo
→ Dashboard sets dashboard-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 Ionize IAM OAuth2 authorization endpoint with a PKCE code_challenge and code_challenge_method=S256. The browser follows the redirect to the Ionize IAM login page.
Step 2: Complete Authentication
Log in at the Ionize IAM login page. After successful authentication, the OAuth2 server redirects back to Dashboard's callback route (/api/auth/callback).
Step 3: Session Cookie Set
On callback success, Dashboard sets the dashboard-session cookie:
| Attribute | Value |
|---|---|
| Name | dashboard-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 "dashboard-session=<cookie-value>" http://localhost:4001/api/settings
# Example: get a single setting
curl -b "dashboard-session=<cookie-value>" http://localhost:4001/api/settings/captcha.enabled
# Example: write a setting
curl -b "dashboard-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 >dashboard-session).
Session Check Lifecycle (AuthProvider)
When a user opens Dashboard 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 Dashboard (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 → Auth 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 dashboard-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 Dashboard detects an unauthenticated user. It is not a bug. After login completes, the dashboard-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 Dashboard (:3001) and IAM Dashboard (:4001) each have their own dashboard-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
Dashboard has no server-to-server authentication path. There is no API key, bearer token, or client_credentials flow for Dashboard.
Why: Dashboard 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 Ionize IAM OAuth2 server's client_credentials flow directly (see orbit#50 for the planned M2M capability). Do not attempt to build an alternative auth path into Dashboard.
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 (Ionize identity APIs enforce auth) | - |
/api/kratos-admin/** | ANY | Proxy (Ionize identity APIs enforce auth) | - |
/api/iam-kratos/** | ANY | Proxy (Ionize identity APIs enforce auth) | - |
/api/iam-kratos-admin/** | ANY | Proxy (Ionize identity APIs enforce auth) | - |
/api/hydra/** | ANY | Proxy (Ionize identity APIs enforce auth) | - |
/api/hydra-admin/** | ANY | Proxy (Ionize 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 Ionize identity store and embedded in the session cookie. It cannot be changed without re-logging in.
Error Responses
All Dashboard 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 dashboard-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 dashboard-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 Ionize 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 dashboard-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("dashboard-session", ...) call with an inline options object outside this helper fails the build. See Dashboard 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
dashboard-sessioncookie is tied to a specific Ionize identity (kratosIdentityId). If a user exercises their right to erasure, their identity record must be deleted from the Ionize 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 Ionize identity service APIs. These routes bypass Dashboard session auth by design.
How they are secured instead:
- Dashboard decrypts the Ionize identity service API key from the settings store and injects it as an
Authorizationheader before forwarding the request. - The Ionize identity service verifies the API key server-side on every proxied request.
- Security relies on network isolation: port 4001 (IAM Dashboard) 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 Ionize 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, Dashboard 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, Dashboard 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
dashboard-sessioncookie is HMAC-SHA256-signed withSESSION_SIGNING_KEY(notENCRYPTION_KEY, these are separate keys as of orbit#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 orbit#51 for the security fix that enforced this. - Port exposure: Port 4001 (IAM Dashboard) must not be internet-accessible. The proxy routes to Ionize identity service admin APIs are authenticated by API keys, not by Dashboard 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 orbit#52 for PKCE enforcement details.
Related Issues
- orbit#50, Planned M2M credential flow (server-to-server auth)
- orbit#51, Security fix:
proxy.ts→middleware.ts(auth enforcement was dead code) - orbit#52, PKCE S256 enforcement and JWKS verification
- orbit#57, Security fix: session cookie
Secureflag,SameSite=Strict, andmaxAgecap - orbit#60, Standardized 401/403 error response shape (
not_authenticated/forbidden) - orbit#61, This document
- orbit#63, PKCE + public client OAuth2 integration guide
- orbit#99, SESSION_SIGNING_KEY separation from ENCRYPTION_KEY (see session-signing-key.md)
- orbit#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)