Skip to main content

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

TokenTTLStored inPurpose
Access token15 minutesMemory (never localStorage or cookie)Authorization: Bearer header on every API request
Refresh token7 daysHttpOnly; Secure; SameSite=Strict cookieObtain 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",
"email": "[email protected]",
"name": "Alice",
"email_verified": false,
"tenant_id": "01j9p3kz5f00000000000000",
"roles": ["owner"],
"created_at": "2026-04-15T09:12:00Z"
}
}

After registration the IAM service:

  1. Creates the user record.
  2. Creates the tenant (name from organization).
  3. Assigns the user the Owner role (wildcard *).
  4. Creates a system wallet for the tenant.
  5. Sends an email verification link (TTL: 24 hours).
  6. 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)

RuleValue
Minimum length12 characters
Maximum length128 characters
Complexity rulesNone — NIST 800-63B explicitly discourages character-class requirements
Common password checkChecked against a list of 100 000+ breached passwords. Rejected with 400.
Hashing algorithmArgon2id — 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",
"email": "[email protected]",
"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 → same 401
  • POST /auth/register — duplicate email → 201 with pending status (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",
"email": "[email protected]",
"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() memberTypeDescription
userUser | nullCurrent authenticated user or null
isLoadingbooleanTrue while an auth call is in flight
login(credentials)async fnEmail+password login
logout()async fnRevokes refresh token, clears state
refresh()async fnManually refresh access token (SDK calls this automatically)
register(data)async fnRegister 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

ScenarioHTTPtype URI suffix
Invalid credentials401unauthorized
Access token expired401token-expired
Refresh token expired401refresh-token-expired
MFA required403mfa-required
Tenant suspended402tenant-suspended
Password too common400validation-error (code: BREACHED_PASSWORD)
Password too short400validation-error (code: TOO_SHORT)