AETHERLABS

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.agency

For 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:3001

Step 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.agency

The SSO session is two cookies, set by the API on login and refreshed on rotation:

CookiehttpOnlysecuresameSiteDefault maxAge
accessTokenyestrue in productionnone in production, lax in dev1 hour
refreshTokenyestrue in productionnone in production, lax in dev7 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:3003

Step 2 — Set your app's environment variables

Frontend:

VITE_AETHERLABS_API_URL=https://sso.aetherlabs.agency
VITE_AETHERLABS_ACCOUNTS_URL=https://accounts.aetherlabs.agency

Backend (only if you verify tokens locally):

AETHERLABS_API_URL=https://sso.aetherlabs.agency
JWT_ACCESS_SECRET=the-same-value-as-the-sso-api

Locally, 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-path

The API refuses to redirect anywhere that is not allowlisted, so this cannot be used as an open redirect:

  • The redirect_to origin must appear in ALLOWED_ORIGINS.
  • First-party apps can instead pass a short app key with ?app=<key>, which the login page resolves server-side. Built-in keys:
KeyProductionDevelopment
accountshttps://accounts.aetherlabs.agencyhttp://localhost:3001
myaccounthttps://myaccount.aetherlabs.agencyhttp://localhost:3002
adminhttps://admin.aetherlabs.agencyhttp://localhost:3000
adminapihttps://adminapi.aetherlabs.agencyhttp://localhost:5001
ssohttps://sso.aetherlabs.agencyhttp://localhost:4000
ssoadminhttps://admin.sso.aetherlabs.agencyhttp://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_TOKEN

or 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-admin

Step 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

  1. Add your app's origin to ALLOWED_ORIGINS.
  2. In production, set COOKIE_DOMAIN=.aetherlabs.agency.
  3. Add a frontend auth guard that calls /api/auth/me with credentials: "include".
  4. Redirect unauthenticated users to /login?app=<key> or /login?redirect_to=….
  5. Wire logout to /api/auth/logout.
  6. Verify the JWT locally (iss aetherlabs-sso, aud aetherlabs) or call /api/auth/me.
  7. Enforce roles and permissions on the backend.
  8. 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

On this page