User Sessions — Anomaly Detection & Risk Scoring
Why session anomaly detection?
Section titled “Why session anomaly detection?”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.
Package structure
Section titled “Package structure”DirectoryGranit.Identity.Abstractions/
UserSessionDescriptor,UserSessionRiskAssessment,IUserSessionAnomalyDetector,IUserSessionRiskStore, the...Etointegration 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
IUserSessionRiskStoreandIDeviceTrustStore, persisted in the Identity DbContext (identity_user_session_risks,identity_device_trusts)- …
| Package | Role | Depends on |
|---|---|---|
Granit.Identity.Abstractions | Contracts + no-op detector + in-memory store | Granit |
Granit.Identity.AnomalyDetection | Heuristic + AI detector, risk evaluator | Granit.AI, Granit.Identity.Abstractions |
Granit.Identity.EntityFrameworkCore | Durable IUserSessionRiskStore, mapped into the User DbContext | Granit.Persistence.EntityFrameworkCore, Granit.Identity.Abstractions |
The session descriptor
Section titled “The session descriptor”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.
Heuristics
Section titled “Heuristics”Three deterministic checks run with no external dependency and no cost:
| Heuristic | Signal | Contribution |
|---|---|---|
| Impossible travel | Great-circle (Haversine) distance between consecutive session locations vs. elapsed time exceeds MaxTravelKilometersPerHour (default 1000 km/h) | High |
| New country | Candidate Location.CountryCode not seen in any prior session | Low |
| New device | UserAgent device family (DeviceFingerprint.Family) never seen before | Low |
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.
The AI layer (opt-in)
Section titled “The AI layer (opt-in)”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}Rate limiting the AI calls
Section titled “Rate limiting the AI calls”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 } }}| Key | Type | Default | Description |
|---|---|---|---|
Identity:AnomalyDetection:MaxAiCallsPerHourPerUser | int | 50 | AI assessments per user per hour. Checked first. |
Identity:AnomalyDetection:MaxAiCallsPerHourPerTenant | int | 500 | AI assessments per tenant per hour. |
Identity:AnomalyDetection:MaxTravelKilometersPerHour | double | 1000 | Impossible-travel speed threshold. |
Persisting verdicts
Section titled “Persisting verdicts”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_).
End-to-end flow
Section titled “End-to-end flow”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
Device trust dampens anomaly noise
Section titled “Device trust dampens anomaly noise”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 level | On an actively-trusted device |
|---|---|
High | Preserved — a trusted device can still be compromised |
Medium | Downgraded to Low, the trusted_device reason is appended, and the SuspiciousUserSessionDetectedEto alert is suppressed |
Low / None | Unchanged |
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.
Public API summary
Section titled “Public API summary”| Category | Key types | Package |
|---|---|---|
| Contracts | UserSessionDescriptor, UserSessionRiskAssessment, UserSessionRiskVerdict, UserSessionRiskLevel | Granit.Identity.Abstractions |
| Detection | IUserSessionAnomalyDetector, IUserSessionRiskStore, SuspiciousUserSessionDetectedEto | Granit.Identity.Abstractions |
| Device trust | IDeviceTrustStore, DeviceTrustVerdict, DeviceTrustLevel | Granit.Identity.Abstractions |
| AI + heuristics | IUserSessionRiskEvaluator, IdentityAnomalyDetectionOptions | Granit.Identity.AnomalyDetection |
| Persistence | EF Core IUserSessionRiskStore, GranitIdentityDbProperties | Granit.Identity.EntityFrameworkCore |
Compliance
Section titled “Compliance”- 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.
See also
Section titled “See also”- IP Geolocation — supplies the
GeoLocationdriving 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
IStructuredCompletionabstraction the AI layer builds on - Audit Log — authentication audit, owned at its source