05 — Roles and Tenants¶
Understanding the Data Model¶
Before assigning roles, you need to understand how apps, tenants, and users relate to each other.
App (your registered product)
│
├── Tenant A (an organization within your app)
│ ├── User 1 — role: owner
│ ├── User 2 — role: admin
│ └── User 3 — role: viewer
│
└── Tenant B (another organization)
├── User 2 — role: staff ← same user, different role in different tenant
└── User 4 — role: admin
Key facts:
- A Tenant is an organization or workspace within your app (e.g. a company using your SaaS)
- A User is a global identity (phone/email) — they exist once, can belong to many tenants
- A Role is always scoped to (user, tenant, app) — the same user can have different roles in different tenants
- Roles are embedded in the JWT automatically on each login and token refresh — you don't fetch them separately
Available Roles¶
| Role | Typical Use |
|---|---|
owner |
Full control — usually the tenant creator |
admin |
Manage members and settings |
staff |
Day-to-day operations access |
viewer |
Read-only access |
Roles in the JWT¶
After login, the access token includes a roles claim:
Checking roles in your backend middleware:
// Require a specific role for a tenant-scoped route
function requireRole(role: string) {
return (req: Request, res: Response, next: NextFunction) => {
const hasRole = req.auth?.roles?.includes(role);
if (!hasRole) {
return res.status(403).json({ error: 'FORBIDDEN', message: 'Insufficient role.' });
}
next();
};
}
// Usage
app.delete('/api/members/:id', requireAuth, requireRole('admin'), handler);
Creating a Tenant¶
Create a tenant via the admin API. Use the X-Admin-Key header (your master admin key, stored server-side).
Request body:
Response (201 Created):
{
"id": "tenant-uuid",
"app_id": "app-uuid",
"name": "Acme Corporation",
"created_at": "2026-05-04T10:00:00.000Z"
}
Node.js example:
const res = await fetch(
`${process.env.AUTH_SERVICE_URL}/admin/apps/${process.env.AUTH_APP_ID}/tenants`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Admin-Key': process.env.MASTER_ADMIN_KEY,
},
body: JSON.stringify({ name: orgName }),
}
);
const { id: tenantId } = await res.json();
The returned id is the tenant_id you use in all role assignment calls.
POST /auth/roles/assign — Assign a Role¶
Called by your backend (typically on user invitation or signup completion).
Request body:
{
"app_id": "YOUR_APP_ID",
"app_secret":"YOUR_APP_SECRET",
"user_id": "user-uuid",
"tenant_id": "tenant-uuid",
"role": "admin"
}
Response (200 OK):
cURL:
curl -X POST https://auth-engine-efb8.onrender.com/auth/roles/assign \
-H "Content-Type: application/json" \
-d '{
"app_id": "YOUR_APP_ID",
"app_secret": "YOUR_APP_SECRET",
"user_id": "user-uuid",
"tenant_id": "tenant-uuid",
"role": "admin"
}'
PowerShell:
Invoke-RestMethod `
-Uri "https://auth-engine-efb8.onrender.com/auth/roles/assign" `
-Method POST `
-ContentType "application/json" `
-Body (@{
app_id = "YOUR_APP_ID"
app_secret = "YOUR_APP_SECRET"
user_id = "user-uuid"
tenant_id = "tenant-uuid"
role = "admin"
} | ConvertTo-Json)
The role is written to the database immediately. However, the user's existing JWT is stateless — it won't reflect the new role until the user's client calls
/auth/refreshor re-logs in. For instant propagation (e.g. onboarding, permission escalation), callPOST /auth/revoke-userimmediately after assigning the role. This kills all active sessions for that user, forcing a silent re-login that picks up the new role automatically. See the Revoke-User section below.
POST /auth/roles/revoke — Remove a Role¶
Removes the user's role from a tenant. The user keeps their identity but loses all permissions in that tenant.
Request body:
{
"app_id": "YOUR_APP_ID",
"app_secret":"YOUR_APP_SECRET",
"user_id": "user-uuid",
"tenant_id": "tenant-uuid"
}
Response (200 OK):
cURL:
curl -X POST https://auth-engine-efb8.onrender.com/auth/roles/revoke \
-H "Content-Type: application/json" \
-d '{
"app_id": "YOUR_APP_ID",
"app_secret": "YOUR_APP_SECRET",
"user_id": "user-uuid",
"tenant_id": "tenant-uuid"
}'
POST /auth/revoke-user — Force Role Propagation¶
Invalidates all active refresh tokens for a user in a specific app. Call this immediately after
/auth/roles/assign or /auth/roles/revoke when you need the role change to take effect instantly.
The user's next API call returns a 401 TOKEN_REVOKED. Their client silently calls /auth/refresh
(or re-logs in), which issues a fresh JWT with the updated roles embedded.
Request body:
Response (200 OK):
sessions_revoked is the number of active refresh-token families that were killed.
Production pattern — assign role then force propagation:
async function assignRoleAndPropagate(
userId: string, tenantId: string, role: string
) {
// 1. Write the new role to the DB
await fetch(`${process.env.AUTH_SERVICE_URL}/auth/roles/assign`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
app_id: process.env.AUTH_APP_ID,
app_secret: process.env.AUTH_APP_SECRET,
user_id: userId,
tenant_id: tenantId,
role,
}),
});
// 2. Kill all sessions — user's client will silently refresh and get new token with role
const revokeRes = await fetch(`${process.env.AUTH_SERVICE_URL}/auth/revoke-user`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
app_id: process.env.AUTH_APP_ID,
app_secret: process.env.AUTH_APP_SECRET,
user_id: userId,
}),
});
const { sessions_revoked } = await revokeRes.json();
console.log(`Role propagated. ${sessions_revoked} session(s) invalidated.`);
}
This is a stateless architecture. Access tokens live until their 30-minute
exp. Only refresh tokens are revoked. Any existing access token stays valid until it expires. For security-critical role removal, additionally validate roles server-side on sensitive routes.
Common Patterns¶
On User Signup — Create Tenant + Assign Owner¶
// In your signup handler, after successful OTP verification:
async function onUserSignup(userId: string, orgName: string) {
// 1. Create tenant in your own DB
const tenant = await db.query(
`INSERT INTO tenants (app_id, name) VALUES ($1, $2) RETURNING id`,
[process.env.AUTH_APP_ID, orgName]
);
const tenantId = tenant.rows[0].id;
// 2. Assign owner role via auth service
await fetch(`${process.env.AUTH_SERVICE_URL}/auth/roles/assign`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
app_id: process.env.AUTH_APP_ID,
app_secret: process.env.AUTH_APP_SECRET,
user_id: userId,
tenant_id: tenantId,
role: 'owner',
}),
});
return { tenantId };
}
On User Invitation — Assign Role After Login¶
// After invitee completes OTP login:
async function onInviteAccepted(userId: string, tenantId: string, role: string) {
await fetch(`${process.env.AUTH_SERVICE_URL}/auth/roles/assign`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
app_id: process.env.AUTH_APP_ID,
app_secret: process.env.AUTH_APP_SECRET,
user_id: userId,
tenant_id: tenantId,
role,
}),
});
}
Common Errors¶
| Error Code | Status | Cause |
|---|---|---|
TOKEN_INVALID |
401 | Wrong app_secret |
USER_NOT_FOUND |
404 | user_id does not exist in this app |
TENANT_NOT_FOUND |
404 | Tenant doesn't exist or belongs to a different app |
USER_DELETED |
403 | User account is soft-deleted |
VALIDATION_ERROR |
400 | Invalid role value or missing fields |
Next Steps¶
- 06 — Error Reference — Complete error code list
- 07 — Security Checklist — Pre-launch verification