Same-Domain SSO
First-party cookie SSO for Aether Labs apps that share the *.aetherlabs.agency parent domain.
Same-Domain SSO
Use this guide for Aether Labs-owned apps under the same parent domain as the SSO, such as:
admin.aetherlabs.agency
myaccount.aetherlabs.agency
billing.aetherlabs.agency
hosting.aetherlabs.agencyFor these apps, prefer first-party cookie SSO over OAuth. You usually do not
need an OAuth client, a client_secret, or PKCE.
When to use this flow
Use same-domain cookie SSO when all of the following are true:
- the app is owned by Aether Labs,
- it can trust Aether Labs SSO,
- it is hosted under the same parent domain (
*.aetherlabs.agency), - the browser can send the SSO cookies to the API, and
- the app does not need a third-party OAuth consent screen.
If your app is third-party, customer-hosted, or on an unrelated domain, use the OAuth integration guide instead.
How the flow works
User opens your app
↓
App checks the session: GET /api/auth/me (cookies included)
↓
If 401, app redirects to the login UI
↓
User logs in at accounts.aetherlabs.agency
↓
SSO sets httpOnly cookies on the parent domain
↓
Browser returns to your app
↓
App calls its APIs with cookies (or a Bearer token)Production API: https://sso.aetherlabs.agency
Login UI: https://accounts.aetherlabs.agency
Local dev API: http://localhost:4000
Local dev login: http://localhost:3001Step 1 — Configure the SSO API for your app
The SSO admin adds your app's origin to ALLOWED_ORIGINS. For production subdomain SSO:
NODE_ENV=production
COOKIE_DOMAIN=.aetherlabs.agency
ALLOWED_ORIGINS=https://accounts.aetherlabs.agency,https://myaccount.aetherlabs.agency,https://yourapp.aetherlabs.agencyThe SSO session is two cookies, set by the API on login and refreshed on rotation:
| Cookie | httpOnly | secure | sameSite | Default maxAge |
|---|---|---|---|---|
accessToken | yes | true in production | none in production, lax in dev | 1 hour |
refreshToken | yes | true in production | none in production, lax in dev | 7 days |
Production requires HTTPS
secure + sameSite=none in production means the browser only sends these
cookies over HTTPS. Set COOKIE_DOMAIN=.aetherlabs.agency so every subdomain
shares the session.
The JWT inside accessToken is valid for 24 hours even though the cookie
expires after 1 hour. Always drive your own expiry from the token's exp claim,
not the cookie lifetime.
For local development:
COOKIE_DOMAIN=localhost
ALLOWED_ORIGINS=http://localhost:3001,http://localhost:3002,http://localhost:3003Step 2 — Set your app's environment variables
Frontend:
VITE_AETHERLABS_API_URL=https://sso.aetherlabs.agency
VITE_AETHERLABS_ACCOUNTS_URL=https://accounts.aetherlabs.agencyBackend (only if you verify tokens locally):
AETHERLABS_API_URL=https://sso.aetherlabs.agency
JWT_ACCESS_SECRET=the-same-value-as-the-sso-apiLocally, point these at http://localhost:4000 (API) and http://localhost:3001
(login UI).
Step 3 — Add a frontend auth guard
On load, check the session with GET /api/auth/me and cookies included. On
success render the app; on 401, redirect to the login UI.
const API_BASE = import.meta.env.VITE_AETHERLABS_API_URL ?? 'http://localhost:4000';
const ACCOUNTS_URL =
import.meta.env.VITE_AETHERLABS_ACCOUNTS_URL ?? 'http://localhost:3001';
export async function getMe() {
const res = await fetch(`${API_BASE}/api/auth/me`, {
credentials: 'include',
});
if (!res.ok) throw new Error(String(res.status));
const data = await res.json();
return data.user;
}
export function redirectToLogin() {
const redirectTo = `${window.location.origin}${window.location.pathname}${window.location.search}`;
window.location.href = `${ACCOUNTS_URL}/login?redirect_to=${encodeURIComponent(
redirectTo,
)}`;
}function AuthGuard({ children }: { children: React.ReactNode }) {
const [status, setStatus] = useState<'checking' | 'ok' | 'redirecting'>('checking');
useEffect(() => {
getMe()
.then(() => setStatus('ok'))
.catch(() => {
setStatus('redirecting');
redirectToLogin();
});
}, []);
if (status === 'checking') return <div>Loading…</div>;
if (status === 'redirecting') return <div>Redirecting…</div>;
return <>{children}</>;
}Step 4 — Honor the login redirect contract
Send unauthenticated users to the login UI with a redirect_to parameter:
https://accounts.aetherlabs.agency/login?redirect_to=https%3A%2F%2Fyourapp.aetherlabs.agency%2Fcurrent-pathThe API refuses to redirect anywhere that is not allowlisted, so this cannot be used as an open redirect:
- The
redirect_toorigin must appear inALLOWED_ORIGINS. - First-party apps can instead pass a short app key with
?app=<key>, which the login page resolves server-side. Built-in keys:
| Key | Production | Development |
|---|---|---|
accounts | https://accounts.aetherlabs.agency | http://localhost:3001 |
myaccount | https://myaccount.aetherlabs.agency | http://localhost:3002 |
admin | https://admin.aetherlabs.agency | http://localhost:3000 |
adminapi | https://adminapi.aetherlabs.agency | http://localhost:5001 |
sso | https://sso.aetherlabs.agency | http://localhost:4000 |
ssoadmin | https://admin.sso.aetherlabs.agency | http://localhost:3003 |
Override them with LOGIN_REDIRECTS="key=url,key=url" if a deployment differs.
An optional ?path=/orders must be a single-slash, same-origin relative path.
No open redirects
Use ?app=<key> where possible. It is resolved on the server, so a caller can
never point a user at an off-site URL.
Step 5 — Authenticate requests on your backend
Option A — Verify the JWT locally
Use this when your backend receives Authorization: Bearer <accessToken>. The
access token is signed with:
issuer: aetherlabs-sso
audience: aetherlabs
algorithm: HS256
secret: JWT_ACCESS_SECRET (shared with the SSO API)import jwt from 'jsonwebtoken';
const JWT_ACCESS_SECRET = process.env.JWT_ACCESS_SECRET!;
export function requireAuth(req, res, next) {
const header = req.headers.authorization;
const token = header?.startsWith('Bearer ') ? header.slice(7) : undefined;
if (!token) return res.status(401).json({ error: 'Access token required' });
try {
req.user = jwt.verify(token, JWT_ACCESS_SECRET, {
issuer: 'aetherlabs-sso',
audience: 'aetherlabs',
});
next();
} catch {
return res.status(401).json({ error: 'Invalid access token' });
}
}Option B — Ask the SSO API
Use this when you want fresh roles and permissions, or you do not want to share the signing secret:
GET https://sso.aetherlabs.agency/api/auth/me
Authorization: Bearer ACCESS_TOKENor with cookies:
fetch('https://sso.aetherlabs.agency/api/auth/me', { credentials: 'include' });For admin-only apps, there is a dedicated check that returns 200 for admin-tier users and 403 otherwise:
GET https://sso.aetherlabs.agency/api/auth/verify-adminStep 6 — Enforce roles and permissions
GET /api/auth/me returns the user with roles and permissions:
{
"user": {
"id": "uuid",
"email": "user@example.com",
"name": "Jane Doe",
"roles": ["admin"],
"permissions": ["users:read", "oauth_clients:write"],
"adminRole": "admin",
"is_active": true,
"email_verified": true
}
}The JWT itself carries only roles — permissions are resolved live from the
database. The frontend can use this to show or hide UI, but the backend must
still enforce authorization:
function requireRole(role: string) {
return (req, res, next) => {
if (!req.user?.roles?.includes(role)) {
return res.status(403).json({ error: 'Forbidden' });
}
next();
};
}See Roles & permissions for the full model.
Step 7 — Refresh and logout
Refresh through the refresh cookie; the API rotates the token pair and sets new cookies:
await fetch(`${API_BASE}/api/auth/refresh-token`, {
method: 'POST',
credentials: 'include',
});Logout revokes the refresh token and clears the cookies:
await fetch(`${API_BASE}/api/auth/logout`, {
method: 'POST',
credentials: 'include',
});Checklist
- Add your app's origin to
ALLOWED_ORIGINS. - In production, set
COOKIE_DOMAIN=.aetherlabs.agency. - Add a frontend auth guard that calls
/api/auth/mewithcredentials: "include". - Redirect unauthenticated users to
/login?app=<key>or/login?redirect_to=…. - Wire logout to
/api/auth/logout. - Verify the JWT locally (iss
aetherlabs-sso, audaetherlabs) or call/api/auth/me. - Enforce roles and permissions on the backend.
- Do not add OAuth or PKCE unless the app genuinely needs OAuth semantics.
For AI agents
Implement first-party Aether Labs SSO for this app using cookie-based
same-domain auth (NOT OAuth).
Config:
- API: https://sso.aetherlabs.agency (local: http://localhost:4000)
- login UI: https://accounts.aetherlabs.agency (local: http://localhost:3001)
Requirements:
1. Add env vars for the API URL and login UI URL.
2. Add an auth guard that calls GET /api/auth/me with credentials: "include".
3. Redirect unauthenticated users to /login?redirect_to=<current URL>.
4. Add logout via POST /api/auth/logout with credentials: "include".
5. Use roles/permissions from /api/auth/me for UI decisions.
6. Enforce authorization on the backend (verify the JWT with issuer
"aetherlabs-sso", audience "aetherlabs", secret JWT_ACCESS_SECRET, or call
/api/auth/me).
7. Do NOT add OAuth, client_secret, authorization-code handling, or PKCE.Next steps
- Need OAuth instead? See OAuth integration.
- Roles and the permission catalog: Roles & permissions.
- Endpoint details: API reference.