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
- 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. - Your product authenticates the user as usual.
- Your server signs the JWT and redirects to
https://<academy>/sso?jwt=<token>&return_to=<path>. - Aladia verifies the token, opens the session (an
httpOnlycookie of the academy) and takes the user toreturn_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.
| Parameter | Required | What |
|---|---|---|
jwt | yes | the signed token |
return_to | no | where 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:
| Method | Algorithm | What you configure |
|---|---|---|
| Secret (recommended to start) | HS256 | Aladia generates a 32-byte secret and shows it once. Use it as it is, as a string (do not base64-decode it). |
| Public key | RS256 or ES256 | paste the public PEM key; the private key stays in your product |
| JWKS URL | RS256 or ES256 | the 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
| Claim | Required | Type | What |
|---|---|---|---|
sub | yes | string (≤ 255) | the user id in your product, stable. It is the link key: a new sub = a new user |
aud | yes | string or array | the academy host: academy.product.com (also https://academy.product.com); name.aladia.io works too |
iat | yes | Unix seconds | when you signed it; up to 3 minutes in the future is tolerated |
exp | yes | Unix seconds | at most 5 minutes after iat; 2 recommended |
jti | yes | string (≤ 255) | unique per token (a UUID): each token works once |
email | no | string | the user's email |
email_verified | no | boolean | false = Aladia does not use it as the account email |
name, given_name, family_name | no | strings | first and last name; they update the profile at every sign-in |
picture | no | https URL | the picture, if the profile has none |
locale | no | string | it or en (also en-GB): the language of a new account |
role | no | student or teacher | default student. Admin and Owner never come from the JWT |
courses | no | array of ids (≤ 100) | enrollment in academy courses (see below) |
replace_courses | no | boolean | true = removes courses given earlier by SSO that are no longer listed |
groups | no | array of names or ids (≤ 50) | academy teams, for teacher only |
nbf | no | Unix seconds | honoured 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 links and remote login
- 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. Copyreturn_tointo the redirect to/ssoafter 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 thecourseslisted. 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 incourses. Manual or purchased enrollments are never touched.groups: academy teams, by exact name or id; forteacheronly (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.
| Case | What happens |
|---|---|
Email new to Aladia, email_verified not false | new 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 on | the 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: false | account 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.
| Code | Cause | What to do |
|---|---|---|
sso_not_configured | no secret or key | set up the signature |
sso_disabled | mode "Aladia only" or feature not active | choose "Only from your product" or "Mixed" |
token_missing, token_malformed | jwt missing or not in the a.b.c format | check the redirect |
alg_not_allowed | algorithm different from the configured one | sign with the algorithm chosen in Aladia |
kid_unknown | unknown, rotated or revoked kid | use the current secret and its kid |
signature_invalid | wrong signature | wrong secret or key; the secret is used as a string |
key_invalid, jwks_unavailable | invalid or unreachable PEM or JWKS | check the key or the URL (https, public) |
aud_mismatch | aud is not the academy host | aud = academy.product.com |
exp_missing, iat_missing | times missing | add iat and exp |
token_expired | expired (60 s of tolerance) | sign the token right before the redirect |
lifetime_too_long | exp - iat above 5 minutes | use 2 minutes |
iat_in_future, not_yet_valid | server clock ahead | sync with NTP |
jti_missing, jti_reused | jti missing or already used | a new jti per token; do not reuse links |
sub_missing, claim_invalid, role_not_allowed | missing or invalid claims | see the claims table |
account_blocked | the Aladia account is blocked | contact the academy |
link_expired, link_password_invalid, link_password_missing | link confirmation | sign in again from the product; right password; set one with "Forgot password?" |
rate_limited | more than 30 sign-ins a minute from the same IP | try 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.
expat 2 minutes, a randomjtiper token,aud= academy host,kidin 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: trueonly if you really verified the email.- Validate
return_toin 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.