Security & Data
This page describes how Stockisto isolates tenant data, authenticates users, enforces roles, and protects the API. It is written for buyers, IT, and security reviewers who need to understand the real controls in place before approving the platform. Everything below reflects how the application is actually built — multi-tenant isolation in the data layer, cookie-based JWT authentication, role-based access control, and request-level protections (rate limiting, abuse detection, security headers, CORS).
Multi-tenant isolation
Stockisto is a single deployment shared by many tenants. A tenant is a supplier or retailer organization (Supplier, Retailer, or the internal Stockisto operator). Every row of business data carries a TenantId, and isolation is enforced in the persistence layer — not left to individual queries.
Tenant-scoped entities
Any entity that stores supplier or retailer data implements the ITenantScoped marker interface, which requires a TenantId. This interface is the single source of truth that the isolation machinery keys off.
Global query filters (read isolation)
Each module's DbContext (Network, Catalog, Analytics, Activation, Entitlements, and others) applies an EF Core global query filter to every ITenantScoped entity at startup. The filter is applied automatically by reflecting over the model, so new tenant-scoped entities are covered without extra wiring:
modelBuilder.Entity<TEntity>()
.HasQueryFilter(e => _currentTenantId == null || e.TenantId == _currentTenantId);
The _currentTenantId is set once per request via SetCurrentTenant(...), sourced from the tenant_id claim on the caller's JWT. As a result, an ordinary query against retailers, products, analytics events, or any other tenant-scoped table can only ever return rows belonging to the caller's own tenant.
Why the filter is in the data layer
Putting isolation in the DbContext means a developer cannot
accidentally forget a WHERE TenantId = … clause. Unless a query
explicitly opts out, the tenant filter always applies.
Write isolation (TenantInsertGuard)
Read filters do not stop a buggy write path from inserting a row with an empty TenantId — which would orphan the row and silently escape every tenant filter. To close that gap, tenant-scoped contexts register the TenantInsertGuardInterceptor. On SaveChanges, it inspects the change tracker and refuses to insert any ITenantScoped entity whose TenantId is Guid.Empty, throwing loudly at write time:
Refusing to insert {EntityName} with an empty TenantId. Every ITenantScoped
row must be tenant-stamped before SaveChanges — an empty TenantId would orphan
the row and bypass tenant isolation filters.
This is defense-in-depth: reads are filtered by tenant, and writes are guarded so no un-stamped row can ever be created.
Controlled bypass for operators
Cross-tenant access exists only for the internal operator path. Admin and platform-operator code uses EF Core's IgnoreQueryFilters() explicitly — for example, the StockistoAdmin "see all retailers" view, and the tenant lifecycle/erasure service. These call sites are the deliberate exception; tenant-facing API controllers never expose IgnoreQueryFilters() on tenant data.
Reviewer note
The only code permitted to read across tenants is the internal{" "}
StockistoAdmin tooling and the tenant lifecycle service.
Every other controller is constrained to the caller's tenant by the global
query filter.
Authentication
Stockisto uses JWT access tokens carried in an HttpOnly cookie, plus rotating refresh tokens. Three sign-in methods are supported: email + password, Google OAuth, and magic link. All of them converge on the same token issuance and cookie-setting logic.
Tokens and cookies
On a successful sign-in the API issues a signed JWT and sets two cookies:
stockisto_token— the JWT access token. It is HttpOnly (unreadable by JavaScript, so it cannot be exfiltrated by XSS), Secure on HTTPS requests, and SameSite=Lax. The browser sends it automatically; the JWT bearer handler reads it from the cookie when noAuthorizationheader is present (aBearerheader, if supplied, still takes priority).stockisto_tenant— the tenant id, not HttpOnly, so client components can resolve the current tenant without parsing the token. It carries no authorization weight on its own.
Both cookies expire after 7 days. Sign-out deletes both cookies, and the logout endpoint is anonymous so an expired session can still cleanly log out.
The access JWT is signed with HMAC-SHA256 and validated on every request for issuer, audience, lifetime, and signing key, with a 2-minute clock-skew tolerance. The token expires after a short window (default 15 minutes) and carries these claims:
sub— user idemailjti— unique token idtenant_id,tenant_type,tenant_slug- one
roleclaim per assigned role scoped_retailer_id— only forRetailerDataManagerusers (see RBAC)
Refresh tokens and rotation
Alongside the access token, the API issues a refresh token: 64 cryptographically random bytes, stored server-side as an ASP.NET Identity user token with a companion expiry. Refresh tokens have a 7-day absolute window that refreshing cannot extend.
Calling the refresh endpoint rotates the token: issuing new tokens overwrites the stored refresh token, invalidating the previous value. A second use of an old token finds no matching stored token and is rejected with 401 — this is reuse detection. Expired refresh tokens are removed and force a fresh login.
No token in localStorage
Because the access token lives in an HttpOnly cookie, it is never placed in
localStorage or readable application state. Cross-origin calls
use credentials: include so the browser attaches the cookie.
Password policy and lockout
For password-based accounts, ASP.NET Core Identity enforces:
- Minimum length 8, at least one digit
- Account lockout after 5 failed attempts, for 15 minutes
Login fails closed: inactive users, missing tenants, and suspended tenants are all rejected before any token is issued. Suspended tenants receive 403 Account suspended.
Google OAuth
The Google sign-in flow redirects to Google's consent screen, and the Google middleware silently handles the OAuth callback (authorization-code exchange). A completion endpoint then reads the Google identity claims (email, name), finds or provisions the matching user, and issues the same HttpOnly cookie JWT as any other sign-in. New OAuth users are provisioned a trial Supplier tenant. As with password login, inactive users and suspended tenants are rejected.
Magic link
Magic-link sign-in generates a single-use token via ASP.NET Identity's data-protection token provider, encoded together with the user id and emailed as a verify link. The token expires shortly after issue and can only be used once. To prevent email enumeration, the request endpoint always returns the same "If that email exists, a magic link has been sent." response regardless of whether the address is registered. See the Getting Started guide for the end-user flow.
Roles & access control (RBAC)
Authorization is role-based, enforced by ASP.NET Core authorization policies and [Authorize] attributes on controllers. Roles are seeded at startup and carried as role claims in the JWT. The roles are:
| Role | Scope | Typical capabilities |
|---|---|---|
SupplierAdmin | One supplier tenant | Full management of the supplier's retailers, locator, settings |
RetailerAdmin | One retailer tenant | Manage the retailer's own profile and data |
StockistoAdmin | Platform operator | Cross-tenant administration, suspension, GDPR erasure |
SupplierViewer | One supplier tenant | Read-only view within the supplier tenant |
RetailerDataManager | A single retailer | Manage data for one specific retailer only (scoped_retailer_id) |
Named authorization policies map directly to these roles (SupplierAdminOnly, RetailerAdminOnly, StockistoAdminOnly, and AnyAdmin for any of the roles). Endpoints declare the roles they accept — for example, the retailer list is restricted to SupplierAdmin and StockistoAdmin.
Scoped permissions
Two roles are scoped templates for least-privilege delegation:
SupplierViewergrants read-only access within a supplier tenant and does not require a retailer scope.RetailerDataManageris scoped to exactly one retailer. Invitations for this role must include aScopedRetailerId; the service rejects the invitation otherwise. That retailer id is carried as thescoped_retailer_idclaim so the user can only act on their assigned retailer.
Invitations
Team members are added by invitation. Only SupplierAdmin, RetailerAdmin, or StockistoAdmin can invite, and the new user is always created inside the inviting user's tenant — the inviter cannot place a user in another tenant. Invitation acceptance is the only anonymous step (the invitee has no account yet) and is gated by a valid, unexpired invitation token. See the Sharing + Groups guide for how access to locator data is delegated across organizations.
API security controls
Beyond authentication and isolation, the API applies a set of request-level protections, wired in the request pipeline.
Security headers
Every API response carries hardening headers:
Strict-Transport-Security— 1-year HSTS withincludeSubDomainsX-Content-Type-Options: nosniff— blocks MIME-type sniffingX-Frame-Options: DENY— blocks clickjacking (the API returns JSON, not embeddable HTML)Referrer-Policy: strict-origin-when-cross-originContent-Security-Policy: default-src 'none'; frame-ancestors 'none'on JSON endpoints
CORS
CORS uses per-environment allowlists with no wildcard in production. The default policy only permits explicitly configured origins and allows credentials (needed for the cookie). A separate named embed policy governs the public "where to buy" locator and is limited to registered tenant domains — it never allows a wildcard origin, even in development.
Rate limiting
The API enforces sliding-window rate limits, partitioned per tenant for authenticated traffic and per client IP (via X-Forwarded-For) for anonymous traffic:
| Scope | Limit |
|---|---|
| Global (authenticated API) | 500 requests/min per tenant |
| Locator search | 100/min per tenant, 20/min per anonymous IP |
| Embed config | 500/min per tenant |
| Analytics ingestion | 10,000 events/min per tenant (burst-tolerant) |
When a limit is exceeded, the API returns HTTP 429 with a Retry-After: 60 header and a structured JSON body (error: "rate_limit_exceeded").
Abuse detection
An abuse-detection layer runs before rate limiting:
- IPs on the blocklist receive
403. - Repeated rate-limit rejections are counted per IP; 50 rejections within 10 minutes trigger an automatic 1-hour block.
- Requests with empty or known scraper user-agents (e.g.
curl,wget,python-requests) are logged as warnings (not blocked).
The blocklist check fails open — a backing-store error does not block legitimate traffic.
Behind the CDN
In production the API sits behind Azure Front Door, which sets{" "}
X-Forwarded-For. Rate limiting and abuse detection read the real
client IP from that header rather than the immediate connection address.
Data handling
Where data lives
Tenant data is stored in PostgreSQL (with PostGIS for geospatial retailer locations). Each bounded context (Identity, Network, Catalog, Analytics, and others) owns its own schema and migration history, so concerns are separated at the database level.
Personally identifiable information (PII)
PII is concentrated in the identity layer. The ApplicationUser record's Email, PhoneNumber, and UserName fields are PII, as is the Email on pending invitations. These fields are explicitly tracked for GDPR erasure.
Secrets management
In production, application secrets (including the JWT signing key, Google OAuth credentials, and connection strings) are loaded from Azure Key Vault using the application's managed identity. Local and test environments fall back to local configuration and user-secrets, so no production secret is needed to run the app locally.
Audit logging
Tenant lifecycle actions (suspend, reactivate, delete) are written to a TenantEventLog with the event type, actor email, and timestamp. For erasure, the audit entry is written before any data is deleted, so there is always a record that erasure was requested and started, even if the deletion is interrupted.
GDPR erasure
Tenant erasure cascades across every module in a deliberate order, bypassing the tenant query filters (operator action):
- Write the audit log entry first (proof of intent)
- Activation data — locator projects, install-health snapshots
- Analytics events
- Entitlement usage records
- Network data — retailers, locations, supplier–retailer relationships
- Identity data — invitations, users, event logs, and finally the tenant row
Each module's data is removed before the tenant row itself is deleted last, ensuring no orphaned PII or business data remains.
Erasure is irreversible
GDPR erasure permanently deletes all of a tenant's data across every module. Suspension (which is reversible) is the right tool when you only need to disable access without destroying data.
Reviewer summary
- Isolation: every tenant-scoped table is filtered by
TenantIdvia EF Core global query filters; writes are guarded against un-stamped rows; cross-tenant reads require an explicit operator-only opt-out. - Authentication: short-lived HMAC-SHA256 JWTs in HttpOnly cookies, rotating 7-day refresh tokens with reuse detection, password lockout, and email-enumeration-safe magic links.
- Authorization: five RBAC roles including two least-privilege scoped templates; tenant-bounded invitations.
- Transport & abuse: HSTS + hardening headers, no-wildcard CORS allowlists, per-tenant/per-IP rate limits, and automatic IP blocking on sustained abuse.
- Data: per-context PostgreSQL schemas, Key Vault secrets in production, audit logging, and an ordered, fully-cascading GDPR erasure path.
For onboarding and product flows, see the Getting Started guide. For how retailer data enters the system, see the Data Import guide.