Authentication Lifecycle
Every user session in SeptemCore starts with a token pair: a short-lived access token for API calls and a long-lived refresh token to obtain a new access token without re-entering credentials. Both tokens are ES256-signed JWTs issued exclusively by the IAM service.
Token Pair
| Token | TTL | Stored in | Purpose |
|---|---|---|---|
| Access token | 15 minutes | Memory (never localStorage or cookie) | Authorization: Bearer header on every API request |
| Refresh token | 7 days | HttpOnly; Secure; SameSite=Strict cookie | Obtain a new access token when the current one expires |
Rotation: Every POST /auth/refresh call issues a new refresh
token and revokes the previous one. A stolen refresh token that has already
been used is automatically invalidated.
Tenant scope: Refresh tokens are tenant-scoped. A refresh token for tenant A cannot be used to access tenant B, even for the same user.
Registration
POST /auth/register creates a user, a tenant, and assigns the
Owner role in a single atomic transaction. If any step fails, the
entire operation rolls back — there are no orphaned users or empty tenants.
Request
POST https://api.septemcore.com/v1/auth/register
Content-Type: application/json
{
"email": "[email protected]",
"password": "S3cure-P@ssw0rd!",
"name": "Alice",
"organization": "Acme Corp"
}
Response 201 Created
{
"access_token": "eyJhbGciOiJFUzI1NiJ9...",
"refresh_token": "dGhpcyBpcyBhIHJlZnJlc2g...",
"token_type": "Bearer",
"expires_in": 900,
"user": {
"id": "01j9p3kx2e00000000000000",
"name": "Alice",
"email_verified": false,
"tenant_id": "01j9p3kz5f00000000000000",
"roles": ["owner"],
"created_at": "2026-04-15T09:12:00Z"
}
}
After registration the IAM service:
- Creates the user record.
- Creates the tenant (name from
organization). - Assigns the user the Owner role (wildcard
*). - Creates a system wallet for the tenant.
- Sends an email verification link (TTL: 24 hours).
- Issues the token pair and returns immediately — the user is already authenticated; email verification is required to activate money operations, not to use the platform.
Password Requirements (NIST 800-63B)
| Rule | Value |
|---|---|
| Minimum length | 12 characters |
| Maximum length | 128 characters |
| Complexity rules | None — NIST 800-63B explicitly discourages character-class requirements |
| Common password check | Checked against a list of 100 000+ breached passwords. Rejected with 400. |
| Hashing algorithm | Argon2id — 64 MB memory, 1 iteration, 4 threads, 32-byte output |
Login
POST /auth/login handles both the simple case (one tenant) and the
multi-tenant case where the user belongs to more than one organisation.
Refresh Request
POST https://api.septemcore.com/v1/auth/login
Content-Type: application/json
{
"email": "[email protected]",
"password": "S3cure-P@ssw0rd!"
}
Login algorithm
1. IAM finds user by email — password verified in constant time (Argon2id)
2. IAM checks tenant membership for this userId
│
├─ 0 tenants → impossible (register always creates a tenant)
├─ 1 tenant → auto-select → issue JWT with that tenantId
└─ 2+ tenants →
├─ lastTenantId saved? → auto-select → issue JWT
└─ no lastTenantId →
PARTIAL AUTH response:
{
"requires_tenant_selection": true,
"session_token": "tmp_...", ← NOT a JWT, TTL 5 min
"tenants": [{ "id", "name", "logo_url", "roles" }]
}
Client shows tenant selector UI
→ POST /auth/select-tenant { session_token, tenant_id }
→ Full JWT issued
Response 200 OK (single tenant or auto-select)
{
"access_token": "eyJhbGciOiJFUzI1NiJ9...",
"refresh_token": "dGhpcyBpcyBhIHJlZnJlc2g...",
"token_type": "Bearer",
"expires_in": 900,
"user": {
"id": "01j9p3kx2e00000000000000",
"name": "Alice",
"email_verified": true,
"tenant_id": "01j9p3kz5f00000000000000",
"roles": ["owner"],
"mfa_enabled": false
}
}
MFA-required response
When the user has TOTP MFA enabled, login returns an intermediate challenge instead of the full token pair:
{
"mfa_required": true,
"mfa_token": "mfa_01j9p4...",
"mfa_type": "totp"
}
The client must call POST /auth/mfa/verify with the TOTP code and
mfa_token to complete authentication.
Anti-Enumeration
All of the following endpoints return identical timing and identical error responses regardless of whether the email address exists in the database:
POST /auth/login— wrong email / wrong password → same401POST /auth/register— duplicate email →201withpendingstatus (email goes to existing address)POST /auth/request-reset— unknown email →202 Accepted(no body)POST /auth/invite— unknown email → invite still sent
Internal implementation: subtle.ConstantTimeCompare and a minimum
response time of 100 ms enforced with time.Sleep. An attacker cannot
distinguish a correct email from an incorrect one by response timing or
content.
Token Refresh
Access tokens expire after 15 minutes. The SDK and frontend shell
handle refresh automatically — module code never calls refresh()
directly.
Logout Request
POST https://api.septemcore.com/v1/auth/refresh
The refresh token is sent automatically via the HttpOnly cookie.
No request body is needed.
Response 200 OK
{
"access_token": "eyJhbGciOiJFUzI1NiJ9...",
"token_type": "Bearer",
"expires_in": 900
}
A new Set-Cookie header with the new refresh token is included.
The previous refresh token is immediately revoked.
Rotation security: If an attacker obtains a refresh token and uses it after the legitimate user has already refreshed, the IAM service detects a reuse of a revoked token and immediately revokes the entire session (all tokens for that user-tenant pair). The user is forced to re-login.
Logout
POST /auth/logout revokes the current refresh token. Subsequent
calls to /auth/refresh return 401.
POST https://api.septemcore.com/v1/auth/refresh
POST https://api.septemcore.com/v1/auth/logout
The access token is short-lived (15 min) and cannot be revoked server-side — it remains valid until expiry. For high-security workflows where instant access revocation is required, use the permission version counter mechanism (described in Permissions Model).
JWT Payload
{
"sub": "01j9p3kx2e00000000000000",
"tenant_id": "01j9p3kz5f00000000000000",
"roles": ["owner"],
"iat": 1713175920,
"exp": 1713176820,
"iss": "https://api.septemcore.com",
"aud": "platform-kernel"
}
Permissions are NOT in the JWT. Only roles[] are stored. The
Gateway resolves permissions from the role set on every request using the
Valkey cache. See Permissions Model
for the full resolution pipeline.
Current User
GET https://api.septemcore.com/v1/auth/me
Authorization: Bearer <access_token>
Returns the authenticated user's profile, including resolved roles[]
and any custom claims registered by installed modules.
SDK
import { useAuth } from '@platform/sdk-auth';
export function LoginPage() {
const { login, isLoading } = useAuth();
async function handleSubmit(email: string, password: string) {
const result = await login({ email, password });
if (result.requiresTenantSelection) {
// Navigate to tenant selector — shell handles this automatically
}
if (result.mfaRequired) {
// Navigate to MFA verification screen
}
// Otherwise: user is authenticated, token stored in memory
}
}
useAuth() member | Type | Description |
|---|---|---|
user | User | null | Current authenticated user or null |
isLoading | boolean | True while an auth call is in flight |
login(credentials) | async fn | Email+password login |
logout() | async fn | Revokes refresh token, clears state |
refresh() | async fn | Manually refresh access token (SDK calls this automatically) |
register(data) | async fn | Register new user and tenant |
The SDK stores the access token in memory only — it is never written
to localStorage or sessionStorage. The refresh token is an
HttpOnly cookie managed by the browser and the server.
Error Reference
| Scenario | HTTP | type URI suffix |
|---|---|---|
| Invalid credentials | 401 | unauthorized |
| Access token expired | 401 | token-expired |
| Refresh token expired | 401 | refresh-token-expired |
| MFA required | 403 | mfa-required |
| Tenant suspended | 402 | tenant-suspended |
| Password too common | 400 | validation-error (code: BREACHED_PASSWORD) |
| Password too short | 400 | validation-error (code: TOO_SHORT) |