Skip to main content

Authentication

HR Studio 2 uses JWT Bearer tokens for all authenticated requests. There are no cookies or sessions.


Login flow

Client                    API                        DB
│ │ │
│── POST /api/auth/login ─▶ │
│ │── SELECT user ──────────▶│
│ │◀── user row ─────────────│
│ │── BCrypt.Verify() │
│ │── ConfigMergeService ────▶│ (resolve config)
│ │◀── config map ───────────│
│ │── BUILD JWT token │
│◀── LoginResponse ───────│ │
│ { accessToken, │
│ expiresAt, │
│ user, │
│ config, │
│ licenseConfig } │

What is in the JWT

ClaimValue
subusr_user_id (UUID)
emailuser email
jtiunique token ID
user_type1 = SuperAdmin, 2 = TenantAdmin
tenant_idten_internal_id (integer)
first_nameuser first name
last_nameuser last name

LoginResponse fields

FieldTypeDescription
accessTokenstringJWT Bearer token
expiresAtISO 8601 datetimeToken expiry
tokenTypestringAlways "Bearer"
userUserInfoBasic user info
configRecord<string, string>Fully resolved config map for this user
licenseConfigobject or nullTenant license parameters

The config and licenseConfig fields are populated at login and stored in the Redux store. The frontend does not need a separate API call to fetch configuration.

UserInfo fields

FieldTypeDescription
userIdUUIDUser identifier
emailstringUser email address
firstNamestringFirst name
lastNamestringLast name
userTypeIdshort1 = SuperAdmin, 2 = TenantAdmin
tenantIdintInternal tenant ID (ten_internal_id)
mustChangePasswordbooltrue when password has a forced expiry — redirect to change-password screen
avatarUrlstring?Preset path (e.g. /assets/images/avtar/3.jpg) or base64 data URL; null when not set

Token expiry

Tokens expire after the number of minutes configured in appsettings.json → Jwt.ExpiresMinutes (default: 60). There is no refresh token mechanism — the user must log in again when the token expires.


Public configuration

GET /api/auth/public-config

Returns the pre-login feature flags that the frontend needs to render the login and register pages correctly. No authentication required.

Auth: [AllowAnonymous]

Response 200

{
"registrationEnabled": true,
"captchaEnabled": false
}
FieldTypeDescription
registrationEnabledboolfalse when auth.registration.enabled = "false" — hides the Register link
captchaEnabledbooltrue when auth.captcha.enabled = "true" — frontend must solve a POW challenge before submitting the register form

Self-registration

POST /api/auth/register

Creates a new tenant + TenantAdmin account in a single step. This is the browser self-service path; M2M provisioning uses POST /api/cloud/tenant/create instead.

Auth: [AllowAnonymous] — no token required.

Rate limiting: subject to the registration-specific policy (3 requests per IP per hour by default). See Rate Limiting below.

Request (RegisterRequest)

FieldTypeRequiredNote
emailstringyesEmail address, max 255
passwordstringyesMin 8 chars; must pass complexity rules
companyNamestringyesMax 255
fiscalCodestringyesMax 255
firstNamestringyesMax 255
lastNamestringyesMax 255
captchaIdstring?noRequired when auth.captcha.enabled = "true"
captchaProofstring?noRequired when auth.captcha.enabled = "true"

Response 200 — registration successful.

Errors

StatusCondition
400Password fails complexity / missing captcha fields when captcha is enabled / invalid captcha proof
409Email already registered
429Rate limit exceeded
503auth.registration.enabled = "false" (disabled by SuperAdmin)

Controlling self-registration

A SuperAdmin can disable self-registration globally:

POST /api/config/global/set
{ "code": "auth.registration.enabled", "value": "false" }

The endpoint returns 503 Service Unavailable until the key is re-enabled or unset (default: "true").


Email verification

After self-registration the tenant is inactive until the user clicks the link in the verification email. The link contains a raw token; the frontend sends that token to the backend to confirm the address.

POST /api/auth/email/verify

Confirms a user's email address using the raw token from the verification email. Activates the tenant so the user can log in.

Auth: [AllowAnonymous]

Request

FieldTypeRequired
tokenstringyes

Response 204 No Content — email verified; the tenant is now active.

Errors

StatusCondition
400Token is invalid, expired, or already used

Token validity: 72 hours from registration. After expiry the user must request a new email via /api/auth/email/resend.


POST /api/auth/email/resend

Resends the verification email for an unverified account. Always returns 204 regardless of whether the address is registered, to prevent email enumeration.

Auth: [AllowAnonymous]

Rate limiting: subject to the "registration" policy (3 req / IP / hour).

Request

FieldTypeRequired
emailstringyes

Response 204 No Content — email dispatched (or silently ignored if address unknown).


Lock screen

After a configurable period of inactivity the frontend overlays a full-screen lock screen. The user must re-enter their password to resume the session. The existing JWT is not invalidated — the lock is client-side only, backed by a lightweight server-side password check.

POST /api/auth/password/verify

Verifies the current user's password without issuing a new token. Used exclusively by the lock screen unlock flow.

Auth: Bearer token required.

Rate limiting: subject to the "registration" policy (3 req / IP / hour).

Request

FieldTypeRequired
passwordstringyes

Response 204 No Content — password is correct; the client dispatches unlockScreen().

Errors

StatusCondition
401Password is incorrect or user not found
429Rate limit exceeded

Inactivity timeout configuration

The timeout is controlled by the ui.lock.timeout_minutes config key (default: 15 minutes, range: 1–480). A SuperAdmin can set it globally; a TenantAdmin can override it per tenant:

POST /api/config/tenant/set
{ "code": "ui.lock.timeout_minutes", "value": "30" }

The resolved value is delivered to the frontend in the login config map and stored in localStorage under the key hr_lock_timeout_minutes. See Configuration System for details on how config keys work.


User profile

PATCH /api/auth/profile

Updates the authenticated user's first name and last name.

Auth: Bearer token required.

Request

FieldTypeRequired
firstNamestringyes
lastNamestringyes

Response 200 — returns the updated UserInfo object.

Errors

StatusCondition
400firstName or lastName is blank
401Token missing or invalid

PATCH /api/auth/profile/avatar

Updates (or clears) the authenticated user's avatar URL. Accepts a preset path or a base64 data URL produced by the frontend AvatarPicker component. Pass null to remove the avatar.

Auth: Bearer token required.

Request

FieldTypeRequiredNote
avatarUrlstring?yes (nullable)Preset relative path or data:image/... base64; null clears the avatar

Response 200 — returns the updated UserInfo object.

Errors

StatusCondition
401Token missing or invalid

Storage note: base64 data URLs are stored as-is in cloud.users.usr_avatar_url (TEXT column, no size limit enforced at DB level). The frontend caps upload size at 512 KB after base64 encoding and resizes images to 256×256 px before sending.


Password change and reset

POST /api/auth/password/change

Allows an authenticated user to change their own password by providing the current password.

Auth: Bearer token required.

Request

FieldTypeRequired
currentPasswordstringyes
newPasswordstringyes

Response 204 No Content — password changed successfully.

Errors

StatusCondition
400New password fails complexity rules
401currentPassword is incorrect

After a successful change, MustChangePassword is set to false for the user.


POW CAPTCHA

HR Studio 2 uses a self-managed Proof-of-Work (POW) captcha — no Google reCAPTCHA or external service. The server issues a math puzzle; the client must find a nonce such that SHA256(id + nonce) starts with a required hex prefix. Solving takes ~10–50 ms in a browser; solving at bot scale is expensive.

The feature is disabled by default (auth.captcha.enabled = "false"). Enable it via the config API:

POST /api/config/global/set
{ "code": "auth.captcha.enabled", "value": "true" }

POST /api/auth/captcha/challenge

Issues a new POW challenge.

Auth: [AllowAnonymous]

Rate limiting: shares the "registration" policy (3 requests per IP per hour).

Request: empty body.

Response 200

{
"id": "a1b2c3d4...",
"prefix": "0000",
"expires_at": "2026-04-26T10:05:00Z"
}
FieldDescription
idUnique challenge ID (UUID without dashes)
prefixRequired hex prefix; find nonce so SHA256(id + nonce).hex().startsWith(prefix)
expires_atChallenge expires after 5 minutes

Client-side solver (pseudo-code)

async function solveChallenge(id: string, prefix: string): Promise<string> {
let nonce = 0;
while (true) {
const proof = nonce.toString();
const data = new TextEncoder().encode(id + proof);
const hash = await crypto.subtle.digest("SHA-256", data);
const hex = Array.from(new Uint8Array(hash))
.map(b => b.toString(16).padStart(2, "0")).join("");
if (hex.startsWith(prefix)) return proof;
nonce++;
}
}

After solving, attach captchaId and captchaProof to the register request body. The server validates and consumes the challenge (one-time use).


Rate Limiting

All inbound traffic is subject to per-IP rate limiting (built-in ASP.NET Core System.Threading.RateLimiting — no extra NuGet package).

Policies

PolicyApplies toDefault limit
GlobalEvery request1000 req / IP / hour
RegistrationPOST /api/auth/register, POST /api/auth/captcha/challenge3 req / IP / hour

Requests that exceed the limit receive 429 Too Many Requests:

{ "message": "Too many requests. Try again later." }

IP whitelist

Internal / frontend server IPs can be exempted from all limits. In appsettings.Development.json localhost is always whitelisted so integration tests are never blocked:

"RateLimit": {
"WhitelistedIps": ["127.0.0.1", "::1"]
}

For production, add trusted backend / proxy IPs to appsettings.json → RateLimit:WhitelistedIps.

Configuration (appsettings.json)

"RateLimit": {
"GlobalPermitLimit": 1000,
"RegistrationPermitLimit": 3,
"WindowHours": 1,
"WhitelistedIps": []
}

Note (reverse proxy): if the application runs behind nginx or Caddy, RemoteIpAddress will show the proxy IP instead of the real client. Add app.UseForwardedHeaders() before UseRateLimiter() and configure KnownProxies in ForwardedHeadersOptions.


Protecting API endpoints

Authentication required (any logged-in user)

[Authorize]
public IActionResult MyEndpoint() { ... }

SuperAdmin only

[Authorize(Policy = "SuperAdmin")]  // user_type = 1
public IActionResult MyEndpoint() { ... }

TenantAdmin only

[Authorize(Policy = "TenantAdmin")]  // user_type = 2
public IActionResult MyEndpoint() { ... }

SuperAdmin or TenantAdmin

[Authorize(Policy = "SuperAdminOrTenantAdmin")]
public IActionResult MyEndpoint() { ... }

Allow unauthenticated access on an otherwise protected controller

[AllowAnonymous]
public IActionResult PublicEndpoint() { ... }

Reading the current user in a controller

private Guid CurrentUserId =>
Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)
?? User.FindFirstValue("sub")!);

private int CurrentTenantId =>
int.Parse(User.FindFirstValue("tenant_id")!);

private bool IsSuperAdmin =>
User.HasClaim("user_type", "1");

Frontend — sending the token

The api.ts Axios instance automatically attaches the stored token to every request:

// src/services/api.ts — token is read from localStorage
api.defaults.headers.common["Authorization"] = `Bearer ${token}`;

To log out, dispatch the logout() Redux action. This clears the stored token and resets the auth state.


Multi-tenant isolation

Every endpoint that reads or writes data must filter by the current tenant:

// ✅ Correct — always filtered by current tenant
var records = await db.SomeTable
.Where(r => r.tenant_id == CurrentTenantId)
.ToListAsync(ct);

// ❌ Wrong — returns all tenants' data
var records = await db.SomeTable.ToListAsync(ct);

SuperAdmins may pass an explicit tenantId in the request body when they need to act on behalf of a specific tenant.