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
| Claim | Value |
|---|---|
sub | usr_user_id (UUID) |
email | user email |
jti | unique token ID |
user_type | 1 = SuperAdmin, 2 = TenantAdmin |
tenant_id | ten_internal_id (integer) |
first_name | user first name |
last_name | user last name |
LoginResponse fields
| Field | Type | Description |
|---|---|---|
accessToken | string | JWT Bearer token |
expiresAt | ISO 8601 datetime | Token expiry |
tokenType | string | Always "Bearer" |
user | UserInfo | Basic user info |
config | Record<string, string> | Fully resolved config map for this user |
licenseConfig | object or null | Tenant 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
| Field | Type | Description |
|---|---|---|
userId | UUID | User identifier |
email | string | User email address |
firstName | string | First name |
lastName | string | Last name |
userTypeId | short | 1 = SuperAdmin, 2 = TenantAdmin |
tenantId | int | Internal tenant ID (ten_internal_id) |
mustChangePassword | bool | true when password has a forced expiry — redirect to change-password screen |
avatarUrl | string? | 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
}
| Field | Type | Description |
|---|---|---|
registrationEnabled | bool | false when auth.registration.enabled = "false" — hides the Register link |
captchaEnabled | bool | true 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)
| Field | Type | Required | Note |
|---|---|---|---|
email | string | yes | Email address, max 255 |
password | string | yes | Min 8 chars; must pass complexity rules |
companyName | string | yes | Max 255 |
fiscalCode | string | yes | Max 255 |
firstName | string | yes | Max 255 |
lastName | string | yes | Max 255 |
captchaId | string? | no | Required when auth.captcha.enabled = "true" |
captchaProof | string? | no | Required when auth.captcha.enabled = "true" |
Response 200 — registration successful.
Errors
| Status | Condition |
|---|---|
400 | Password fails complexity / missing captcha fields when captcha is enabled / invalid captcha proof |
409 | Email already registered |
429 | Rate limit exceeded |
503 | auth.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
| Field | Type | Required |
|---|---|---|
token | string | yes |
Response 204 No Content — email verified; the tenant is now active.
Errors
| Status | Condition |
|---|---|
400 | Token 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
| Field | Type | Required |
|---|---|---|
email | string | yes |
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
| Field | Type | Required |
|---|---|---|
password | string | yes |
Response 204 No Content — password is correct; the client dispatches unlockScreen().
Errors
| Status | Condition |
|---|---|
401 | Password is incorrect or user not found |
429 | Rate 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
| Field | Type | Required |
|---|---|---|
firstName | string | yes |
lastName | string | yes |
Response 200 — returns the updated UserInfo object.
Errors
| Status | Condition |
|---|---|
400 | firstName or lastName is blank |
401 | Token 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
| Field | Type | Required | Note |
|---|---|---|---|
avatarUrl | string? | yes (nullable) | Preset relative path or data:image/... base64; null clears the avatar |
Response 200 — returns the updated UserInfo object.
Errors
| Status | Condition |
|---|---|
401 | Token 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
| Field | Type | Required |
|---|---|---|
currentPassword | string | yes |
newPassword | string | yes |
Response 204 No Content — password changed successfully.
Errors
| Status | Condition |
|---|---|
400 | New password fails complexity rules |
401 | currentPassword 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"
}
| Field | Description |
|---|---|
id | Unique challenge ID (UUID without dashes) |
prefix | Required hex prefix; find nonce so SHA256(id + nonce).hex().startsWith(prefix) |
expires_at | Challenge 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
| Policy | Applies to | Default limit |
|---|---|---|
| Global | Every request | 1000 req / IP / hour |
| Registration | POST /api/auth/register, POST /api/auth/captcha/challenge | 3 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,
RemoteIpAddresswill show the proxy IP instead of the real client. Addapp.UseForwardedHeaders()beforeUseRateLimiter()and configureKnownProxiesinForwardedHeadersOptions.
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.