AladiaDocs

Customer SSO, developer guide

How your product signs its users in to the Aladia academy with the same account, by signing a JWT. Flow, claims, Node.js, Python and PHP examples (HS256 and RS256/JWKS), deep links, remote login, logout, course enrollment, errors and a security checklist.

Draft · being trialled on pilot workspaces (06/10/2026)

This guide is for the developers of the product (for example a SaaS like ClickUp) that embeds an Aladia academy on its own domain, academy.product.com, and wants its users to get in without a second password. Academy admins find the setup in Customer SSO.

The model is the one used by Featurebase, Thinkific and Zendesk: your product server, which already knows who the user is, signs a short-lived JWT and sends the browser to https://academy.product.com/sso. Aladia verifies the token, creates or updates the user, enrolls them where needed and opens the session.

The flow

sequenceDiagram
  participant U as User's browser
  participant P as Product server
  participant A as Academy (academy.product.com)
  U->>A: opens /courses/123 without a session
  A-->>U: 302 to the product login ?return_to=https://academy.product.com/courses/123
  U->>P: product login (or session already open)
  P->>P: signs the JWT (sub, aud, iat, exp, jti, …)
  P-->>U: 302 https://academy.product.com/sso?jwt=…&return_to=/courses/123
  U->>A: GET /sso?jwt=…
  A->>A: checks signature, alg, aud, exp/iat, single-use jti
  A->>A: creates or updates the user, JIT enrollment
  A-->>U: 302 /courses/123 with the session (httpOnly cookie), JWT removed from the URL
  1. The user reaches the academy without a session. If the academy has a product login URL (remote login), Aladia sends them there with return_to = the full address of the requested page.
  2. Your product authenticates the user as usual.
  3. Your server signs the JWT and redirects to https://<academy>/sso?jwt=<token>&return_to=<path>.
  4. Aladia verifies the token, opens the session (an httpOnly cookie of the academy) and takes the user to return_to, with the token removed from the URL.

Sign the JWT on the server only: never in the browser or in a mobile app, where the secret would be readable.

The sign-in address

https://<academy host>/sso, for example https://academy.product.com/sso or https://name.aladia.io/sso. It is in Settings > Access and security, with a Copy button.

ParameterRequiredWhat
jwtyesthe signed token
return_tonowhere to go after sign-in: a path (/courses/123) or a URL on the same host; anything else goes to the home page

/sso without jwt sends the user to your product login (handy as an "Open the academy" link).

Signature

Choose it in Settings > Access and security > Token signature:

MethodAlgorithmWhat you configure
Secret (recommended to start)HS256Aladia generates a 32-byte secret and shows it once. Use it as it is, as a string (do not base64-decode it).
Public keyRS256 or ES256paste the public PEM key; the private key stays in your product
JWKS URLRS256 or ES256the https address of your JWKS; Aladia picks the key by kid

Aladia accepts only the configured algorithm: the header alg decides nothing (no none, no HS256 instead of RS256). RSA keys of at least 2048 bits, EC keys on the P-256 curve.

kid in the header. The secret has an id (kid, for example k_1a2b3c4d5e6f). Always put it in the header: after a rotation Aladia knows which secret to use. With a multi-key JWKS it is required.

Claims

ClaimRequiredTypeWhat
subyesstring (≤ 255)the user id in your product, stable. It is the link key: a new sub = a new user
audyesstring or arraythe academy host: academy.product.com (also https://academy.product.com); name.aladia.io works too
iatyesUnix secondswhen you signed it; up to 3 minutes in the future is tolerated
expyesUnix secondsat most 5 minutes after iat; 2 recommended
jtiyesstring (≤ 255)unique per token (a UUID): each token works once
emailnostringthe user's email
email_verifiednobooleanfalse = Aladia does not use it as the account email
name, given_name, family_namenostringsfirst and last name; they update the profile at every sign-in
picturenohttps URLthe picture, if the profile has none
localenostringit or en (also en-GB): the language of a new account
rolenostudent or teacherdefault student. Admin and Owner never come from the JWT
coursesnoarray of ids (≤ 100)enrollment in academy courses (see below)
replace_coursesnobooleantrue = removes courses given earlier by SSO that are no longer listed
groupsnoarray of names or ids (≤ 50)academy teams, for teacher only
nbfnoUnix secondshonoured if present (3 minutes of tolerance)

An invalid claim (for example role: "admin", courses not an array) rejects the token with a precise code: a clear error is better than a wrong permission.

Examples: HS256 secret

Environment variables on your product server: ALADIA_SSO_SECRET (the secret), ALADIA_SSO_KID (its kid), ALADIA_ACADEMY_HOST (for example academy.product.com).

Node.js (jsonwebtoken)

import { randomUUID } from 'node:crypto'
import jwt from 'jsonwebtoken'

/* After your product login: user is the authenticated user, returnTo comes from ?return_to. */
export function academyRedirect(user, returnTo = '/') {
    const host = process.env.ALADIA_ACADEMY_HOST
    const token = jwt.sign(
        {
            sub: String(user.id),
            email: user.email,
            email_verified: user.emailVerified,
            given_name: user.firstName,
            family_name: user.lastName,
            locale: user.locale,
            role: user.isTeacher ? 'teacher' : 'student',
            courses: user.academyCourseIds, // Aladia course ids
        },
        process.env.ALADIA_SSO_SECRET, // the string as it is
        { algorithm: 'HS256', keyid: process.env.ALADIA_SSO_KID, audience: host, expiresIn: 120, jwtid: randomUUID() },
    )
    const path = safePath(returnTo, host)
    return `https://${host}/sso?jwt=${token}&return_to=${encodeURIComponent(path)}`
}

/* return_to: only a path or a URL of your academy. */
function safePath(value, host) {
    try {
        const url = new URL(value, `https://${host}`)
        return url.host === host ? `${url.pathname}${url.search}` : '/'
    } catch {
        return '/'
    }
}

Python (PyJWT)

import os, time, uuid
from urllib.parse import quote, urlparse
import jwt  # pip install pyjwt

def academy_redirect(user, return_to="/"):
    host = os.environ["ALADIA_ACADEMY_HOST"]
    now = int(time.time())
    token = jwt.encode(
        {
            "sub": str(user.id),
            "aud": host,
            "iat": now,
            "exp": now + 120,
            "jti": str(uuid.uuid4()),
            "email": user.email,
            "email_verified": user.email_verified,
            "name": user.full_name,
            "role": "teacher" if user.is_teacher else "student",
        },
        os.environ["ALADIA_SSO_SECRET"],  # the string as it is
        algorithm="HS256",
        headers={"kid": os.environ["ALADIA_SSO_KID"]},
    )
    return f"https://{host}/sso?jwt={token}&return_to={quote(safe_path(return_to, host))}"

def safe_path(value, host):
    """return_to: only a path or a URL of your academy."""
    parsed = urlparse(value)
    if parsed.netloc:
        return (parsed.path or "/") if parsed.netloc == host else "/"
    return value if value.startswith("/") and not value.startswith("//") else "/"

PHP (firebase/php-jwt)

<?php
use Firebase\JWT\JWT; // composer require firebase/php-jwt

function academyRedirect(array $user, string $returnTo = '/'): string
{
    $host = getenv('ALADIA_ACADEMY_HOST');
    $now = time();
    $token = JWT::encode([
        'sub' => (string) $user['id'],
        'aud' => $host,
        'iat' => $now,
        'exp' => $now + 120,
        'jti' => bin2hex(random_bytes(16)),
        'email' => $user['email'],
        'email_verified' => true,
        'given_name' => $user['first_name'],
        'family_name' => $user['last_name'],
        'role' => $user['is_teacher'] ? 'teacher' : 'student',
    ], getenv('ALADIA_SSO_SECRET'), 'HS256', getenv('ALADIA_SSO_KID'));

    $path = (str_starts_with($returnTo, '/') && !str_starts_with($returnTo, '//')) ? $returnTo : '/';
    return "https://{$host}/sso?jwt={$token}&return_to=" . rawurlencode($path);
}

Examples: RS256 with a public key or JWKS

Generate a key pair, keep the private key in your product and give Aladia the public key (PEM) or your JWKS URL.

openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out aladia-sso.key
openssl pkey -in aladia-sso.key -pubout -out aladia-sso.pub   # paste it in "Public key"

Node.js

import { createPublicKey, randomUUID } from 'node:crypto'
import fs from 'node:fs'
import jwt from 'jsonwebtoken'

const privateKey = fs.readFileSync(process.env.ALADIA_SSO_PRIVATE_KEY_PATH)
const KID = '2026-10'

export const token = (user, host) =>
    jwt.sign({ sub: String(user.id), email: user.email, role: 'student' }, privateKey, {
        algorithm: 'RS256',
        keyid: KID,
        audience: host,
        expiresIn: 120,
        jwtid: randomUUID(),
    })

/* Your JWKS (e.g. GET /.well-known/aladia-jwks.json): public keys only. */
export const jwks = () => ({
    keys: [{ ...createPublicKey(privateKey).export({ format: 'jwk' }), kid: KID, alg: 'RS256', use: 'sig' }],
})

Python

import os, time, uuid
import jwt  # pip install "pyjwt[crypto]"

PRIVATE_KEY = open(os.environ["ALADIA_SSO_PRIVATE_KEY_PATH"]).read()

def token(user, host):
    now = int(time.time())
    return jwt.encode(
        {"sub": str(user.id), "aud": host, "iat": now, "exp": now + 120, "jti": str(uuid.uuid4()), "role": "student"},
        PRIVATE_KEY,
        algorithm="RS256",
        headers={"kid": "2026-10"},
    )

PHP

<?php
use Firebase\JWT\JWT;

$privateKey = file_get_contents(getenv('ALADIA_SSO_PRIVATE_KEY_PATH'));
$now = time();
$token = JWT::encode([
    'sub' => (string) $user['id'], 'aud' => $host, 'iat' => $now, 'exp' => $now + 120,
    'jti' => bin2hex(random_bytes(16)), 'role' => 'student',
], $privateKey, 'RS256', '2026-10');

Rotating with a JWKS. Publish the new key in the JWKS (with a new kid) before using it; sign with the new one; remove the old one a few minutes later. Aladia caches the JWKS for 10 minutes and downloads it again right away (at most once a minute) when an unknown kid arrives.

Rotating the secret. In Aladia, Regenerate creates a new secret with a new kid; the previous one keeps working for 24 hours. Update ALADIA_SSO_SECRET and ALADIA_SSO_KID in your product within those 24 hours. If the secret leaks: Regenerate with "Revoke the current secret now".

  • Deep link: to land the user on a specific page use return_to: https://academy.product.com/sso?jwt=…&return_to=/courses/6ab516ab857fed8289e3f276.
  • Remote login: with the product login URL configured, anyone opening the academy without a session lands on https://<your login>?return_to=https%3A%2F%2Facademy.product.com%2Fcourses%2F123. Your endpoint: if the user already has a product session, sign the JWT right away, otherwise show the login and then sign. Copy return_to into the redirect to /sso after checking that its host is your academy.

Logout

Log out on Aladia closes the academy session and, if you configured a logout URL, sends the user there (for example to close the product session too). Without a logout URL, in "Only from your product" the user lands on the academy login, not the home page: from the home page they would go back to the product and, with the product session still open, be signed in again straight away.

The two sessions are independent: logging out of your product does not close the academy session by itself.

Course and team enrollment (JIT)

  • Role: student = academy Student; teacher = Member (the teaching staff) and Teacher in the courses listed. The JWT promotes a Student to Teacher but never demotes and never touches Admins and Owners: to remove permissions use People in Aladia.
  • courses: ids of academy courses (the id is in the course URL, /courses/<id>). Every sign-in adds the missing courses; an id that is not in the academy becomes a warning in the log without blocking the sign-in. Course slugs do not exist yet.
  • replace_courses: true: removes the courses the user was enrolled in by SSO that are no longer in courses. Manual or purchased enrollments are never touched.
  • groups: academy teams, by exact name or id; for teacher only (teams are for staff). For students a warning goes to the log.

Existing accounts

sub is the key: the first time Aladia creates the link (academy, sub) → account; afterwards it always uses it, even if the email changes.

CaseWhat happens
Email new to Aladia, email_verified not falsenew account with that email, already verified, no password
Email already on Aladia, linking off (default)a new, separate account with an internal address, for this academy only. Never an automatic link
Email already on Aladia, linking onthe user sees "Link your account" and confirms with that Aladia account's password (5 attempts in 10 minutes); then the link stays
No email or email_verified: falseaccount with an internal address

Accounts created by SSO sign in only from your product: no Aladia password, no "Forgot password?". Accounts with an internal address receive no emails from Aladia.

Errors

The user sees the Sign-in failed page with the code; you also find it in the log in Access and security, with the detail, and you can reproduce it with Test a token.

CodeCauseWhat to do
sso_not_configuredno secret or keyset up the signature
sso_disabledmode "Aladia only" or feature not activechoose "Only from your product" or "Mixed"
token_missing, token_malformedjwt missing or not in the a.b.c formatcheck the redirect
alg_not_allowedalgorithm different from the configured onesign with the algorithm chosen in Aladia
kid_unknownunknown, rotated or revoked kiduse the current secret and its kid
signature_invalidwrong signaturewrong secret or key; the secret is used as a string
key_invalid, jwks_unavailableinvalid or unreachable PEM or JWKScheck the key or the URL (https, public)
aud_mismatchaud is not the academy hostaud = academy.product.com
exp_missing, iat_missingtimes missingadd iat and exp
token_expiredexpired (60 s of tolerance)sign the token right before the redirect
lifetime_too_longexp - iat above 5 minutesuse 2 minutes
iat_in_future, not_yet_validserver clock aheadsync with NTP
jti_missing, jti_reusedjti missing or already useda new jti per token; do not reuse links
sub_missing, claim_invalid, role_not_allowedmissing or invalid claimssee the claims table
account_blockedthe Aladia account is blockedcontact the academy
link_expired, link_password_invalid, link_password_missinglink confirmationsign in again from the product; right password; set one with "Forgot password?"
rate_limitedmore than 30 sign-ins a minute from the same IPtry again in a minute

Security checklist

  • Sign the JWT on the server only; keep the secret in an environment variable or a secret manager, never in code or in the repository.
  • exp at 2 minutes, a random jti per token, aud = academy host, kid in the header.
  • Never role: "admin" or permission data you do not control.
  • A stable, non-reassignable sub (not the email, if users can change it).
  • email_verified: true only if you really verified the email.
  • Validate return_to in your product: only your academy host.
  • Do not log the JWT in your product; Aladia removes it from the URL straight away and answers with Referrer-Policy: no-referrer.
  • Rotate the secret when who knows it changes; if it leaks, "Revoke now".

FAQ

Can I try without signing anyone in? Yes: Test a token in Access and security checks everything like a real sign-in, but creates no session and does not consume the jti.

Staff and admins? Owners, admins and teachers of the workspace can always sign in with their Aladia account from https://<academy>/login, even in "Only from your product": it is the emergency access.

Does it work on name.aladia.io and on the custom domain? Yes, on both; aud can be either.

Does it work in the Aladia mobile app? Not yet: SSO is for the academy web app.

OIDC or SAML? Not in this phase: the signed JWT covers the "my product already has its users" case. SSO for staff (SAML/OIDC) is in the next phases.

On this page