Skip to content

User Sessions — Anomaly Detection & Risk Scoring

A valid session token says the credential is correct. It does not say this is really the user. A stolen session cookie, a phished login, or a hijacked refresh token all present a perfectly valid session. The signal that something is wrong lives in the pattern: a login from Brussels followed eight minutes later by one from São Paulo, a brand-new device family, a country the account has never touched.

Granit’s Identity session-risk packages turn that pattern into a risk verdict. They combine cheap, deterministic heuristics with an optional AI layer, persist the verdict, and raise an integration event when a session looks suspicious — so the session list a user sees can flag it and your handlers can step up authentication or notify the account owner.

  • DirectoryGranit.Identity.Abstractions/ UserSessionDescriptor, UserSessionRiskAssessment, IUserSessionAnomalyDetector, IUserSessionRiskStore, the ...Eto integration event — safe no-op defaults
  • DirectoryGranit.Identity.AnomalyDetection/ Heuristics (impossible travel, new device, new country) + opt-in AI layer over Granit.AI
  • DirectoryGranit.Identity.EntityFrameworkCore/ Durable IUserSessionRiskStore and IDeviceTrustStore, persisted in the Identity DbContext (identity_user_session_risks, identity_device_trusts)
PackageRoleDepends on
Granit.Identity.AbstractionsContracts + no-op detector + in-memory storeGranit
Granit.Identity.AnomalyDetectionHeuristic + AI detector, risk evaluatorGranit.AI, Granit.Identity.Abstractions
Granit.Identity.EntityFrameworkCoreDurable IUserSessionRiskStore, mapped into the User DbContextGranit.Persistence.EntityFrameworkCore, Granit.Identity.Abstractions

The detector reasons over UserSessionDescriptor — a candidate session plus the user’s recent history.

public sealed record UserSessionDescriptor
{
public required string SessionId { get; init; }
public string? UserId { get; init; }
public bool IsCurrent { get; init; }
public DateTimeOffset CreatedAt { get; init; }
public DateTimeOffset? LastAccessedAt { get; init; } // last-activity
public string? UserAgent { get; init; }
public string? IpAddress { get; init; } // raw IP — server-side only, never log in clear
public GeoLocation? Location { get; init; } // derived via Granit.IpGeolocation
}

Location comes from IP Geolocation — the privacy-friendly, country/city read-model rather than the raw IP.

Three deterministic checks run with no external dependency and no cost:

HeuristicSignalContribution
Impossible travelGreat-circle (Haversine) distance between consecutive session locations vs. elapsed time exceeds MaxTravelKilometersPerHour (default 1000 km/h)High
New countryCandidate Location.CountryCode not seen in any prior sessionLow
New deviceUserAgent device family (DeviceFingerprint.Family) never seen beforeLow
public interface IUserSessionAnomalyDetector
{
Task<UserSessionRiskAssessment> AssessAsync(
UserSessionDescriptor candidate,
IReadOnlyList<UserSessionDescriptor> history,
CancellationToken cancellationToken = default);
}

The result combines the heuristic verdict and (if enabled) the AI verdict by taking the higher risk level and the maximum score — the AI can escalate but never silently downgrade a heuristic that already fired.

When Granit.Identity.AnomalyDetection is wired with an AI provider, the detector also asks the model — via IStructuredCompletion from Granit.AI — for a structured verdict: level, score, reason codes, and a short explanation. It degrades gracefully to the heuristic verdict on rate-limit, timeout, or refusal.

public sealed record UserSessionRiskAssessment
{
public UserSessionRiskLevel Level { get; init; } // None | Low | Medium | High
public double Score { get; init; } // normalized [0, 1]
public IReadOnlyList<string> Reasons { get; init; } // machine-readable reason codes
public string? Explanation { get; init; } // ⚠ untrusted model output — see below
}

AI calls cost money and latency, so they are budgeted per hour on two axes. The crucial detail: the per-user budget is checked before the per-tenant budget.

{
"Identity": {
"AnomalyDetection": {
"MaxAiCallsPerHourPerUser": 50,
"MaxAiCallsPerHourPerTenant": 500,
"MaxTravelKilometersPerHour": 1000
}
}
}
KeyTypeDefaultDescription
Identity:AnomalyDetection:MaxAiCallsPerHourPerUserint50AI assessments per user per hour. Checked first.
Identity:AnomalyDetection:MaxAiCallsPerHourPerTenantint500AI assessments per tenant per hour.
Identity:AnomalyDetection:MaxTravelKilometersPerHourdouble1000Impossible-travel speed threshold.

Granit.Identity.EntityFrameworkCore swaps the in-memory store for a durable one backed by the identity_user_session_risks table. The table is mapped into the Identity DbContext (IdentityDbContext), so the risk store shares the identity connection and migrations — there is no separate session-risk database to provision.

Adding the package and calling AddGranitIdentityEntityFrameworkCore is all that is required; the module registers the EF Core–backed IUserSessionRiskStore automatically, overriding the in-memory default.

builder.AddGranitIdentityEntityFrameworkCore(options =>
options.UseNpgsql(builder.Configuration.GetConnectionString("Identity")));
public interface IUserSessionRiskStore
{
Task SetAsync(string userId, string sessionId, UserSessionRiskVerdict verdict, CancellationToken ct);
Task<UserSessionRiskVerdict?> GetAsync(string userId, string sessionId, CancellationToken ct);
Task<IReadOnlyDictionary<string, UserSessionRiskVerdict>> GetManyAsync(
string userId, IReadOnlyCollection<string> sessionIds, CancellationToken ct);
}

The stored UserSessionRiskVerdict carries only Level, Reasons, and AssessedAt — the untrusted Explanation is intentionally not persisted. The risk store has no DbProperties of its own — table prefix and schema follow the canonical GranitIdentityDbProperties (default prefix identity_).

IUserSessionRiskEvaluator orchestrates the pipeline: assess, persist any non-None verdict, and publish a SuspiciousUserSessionDetectedEto integration event for Medium/High risk so downstream handlers (notifications, step-up auth) can react.

sequenceDiagram
    participant App as Login / session pipeline
    participant Eval as IUserSessionRiskEvaluator
    participant Det as IUserSessionAnomalyDetector
    participant RL as AI rate limiter
    participant AI as Granit.AI
    participant Store as IUserSessionRiskStore
    participant Bus as IDistributedEventBus

    App->>Eval: EvaluateAsync(candidate, history)
    Eval->>Det: AssessAsync
    Det->>Det: heuristics (impossible travel, new country/device)
    Det->>RL: per-user budget? then per-tenant budget?
    alt budget available
        RL-->>Det: ok
        Det->>AI: structured completion (sanitized features)
        AI-->>Det: level / score / reasons / explanation
    else exhausted
        RL-->>Det: rate-limited → heuristic verdict only
    end
    Det-->>Eval: max(heuristic, AI)
    Eval->>Store: persist verdict (Level, Reasons, AssessedAt)
    opt Medium / High
        Eval->>Bus: SuspiciousUserSessionDetectedEto
    end

When the user has explicitly trusted the device the session originates from, the evaluator dampens the noise without masking real threats. IUserSessionRiskEvaluator.EvaluateAsync takes the resolved deviceId and consults the IDeviceTrustStore:

Detected levelOn an actively-trusted device
HighPreserved — a trusted device can still be compromised
MediumDowngraded to Low, the trusted_device reason is appended, and the SuspiciousUserSessionDetectedEto alert is suppressed
Low / NoneUnchanged

The deviceId reaches the evaluator only when the browser traverses Granit and the device-trust middleware (UseGranitDeviceTrust()) decoded the cookie: a BFF or OpenIddict front-channel sign-in stamps it onto UserSessionCreatedEto. A pure IdP webhook session is server-to-server with no browser cookie, so it carries no deviceId and is treated as untrusted — the full risk verdict stands.

CategoryKey typesPackage
ContractsUserSessionDescriptor, UserSessionRiskAssessment, UserSessionRiskVerdict, UserSessionRiskLevelGranit.Identity.Abstractions
DetectionIUserSessionAnomalyDetector, IUserSessionRiskStore, SuspiciousUserSessionDetectedEtoGranit.Identity.Abstractions
Device trustIDeviceTrustStore, DeviceTrustVerdict, DeviceTrustLevelGranit.Identity.Abstractions
AI + heuristicsIUserSessionRiskEvaluator, IdentityAnomalyDetectionOptionsGranit.Identity.AnomalyDetection
PersistenceEF Core IUserSessionRiskStore, GranitIdentityDbPropertiesGranit.Identity.EntityFrameworkCore
  • GDPR Art. 5(1)(f) / Art. 32 — integrity and confidentiality: anomaly detection is a technical measure against unauthorized account access; the untrusted model explanation is kept out of persistence and logs by default.
  • ISO 27001 A.8.16 — monitoring activities: suspicious-session events feed your SOC / notification bridge.
  • IP Geolocation — supplies the GeoLocation driving the travel and country heuristics
  • Identity — the canonical session list (/sessions + /devices), now enriched with risk
  • BFF — one backend behind that canonical session API for SPAs
  • AI — the IStructuredCompletion abstraction the AI layer builds on
  • Audit Log — authentication audit, owned at its source