Session risk.
GAA right password from the wrong place is how most accounts are taken. TillAuth scores every sign-in against where the user has signed in lately, and every refresh against the session's own history, and your policy decides whether a stolen password or refresh token gets anywhere.
What is scored
Each signal adds to a score out of 100. A request that matches the history scores 0.
| Signal | Adds | When |
|---|---|---|
impossible_travel | 60 | The user or token moved 500 km or more faster than 900 km/h since the last sighting. |
listed_ip | 50 | The address is on TillGate’s confirmed or widely reported lists. |
browser_changed | 40 | A browser or operating system not seen before. Version updates don’t count. |
country_changed | 35 | A country not seen before. |
network_changed | 10 | A different network: another /24 for IPv4, another /64 for IPv6. |
A sign-in is compared with the user's sessions in the app over the last 90 days, up to 200 of them: a browser, country or network counts as new only if none of them had it, and travel is measured from the latest one with a location. A first sign-in has nothing to compare with, so only a listed address counts. Sessions ended by this policy or by token theft detection are not history.
A refresh is compared with the session it renews. Coming back to the country the session started in is not a change, so a trip home doesn't count against anyone.
A score under 30 is low, 30 to 59 medium, 60 or more high.
The policy
| Mode | What happens |
|---|---|
off | Sign-ins and refreshes are not scored. |
monitor (default) | Every sign-in and refresh is scored and the score is stored with the session. A score of 30 or more writes signin.risk or session.risk to the audit log. Nobody is stopped. |
enforce | At reauth_at (default 60) a sign-in owes one more proof, and a refreshing session ends so the user signs in again. At revoke_at (default 90) a sign-in is blocked, and a refresh ends every session the user has in the app. |
Set it on the app's Policies page, from a terminal, or through the API. Thresholds are whole numbers from 10 to 100, and reauth_at can't be above revoke_at. Fields you leave out keep their value.
tilldev auth apps update my-app --session-risk enforce --reauth-at 60 --revoke-at 90
PATCH https://tilldev.dev/api/auth/admin/apps/<app>
{ "settings": { "session_risk": { "mode": "enforce", "reauth_at": 60, "revoke_at": 90 } } }monitor for a week and look at the app's risky sessions before you enforce. People on mobile networks change address often; that alone adds 10 and never crosses a threshold.At sign-in
| Signed in with | At reauth_at | At revoke_at |
|---|---|---|
| Password, Google, GitHub or your OIDC provider | Their authenticator code or push approval; with neither, a code we email | Blocked, and the user is emailed a password reset link |
| Magic link | Nothing more: the link already proved the inbox | Blocked, and emailed |
| Passkey | Nothing more | Allowed. A passkey is the way back in for someone the policy blocks |
The emailed code is 6 digits. It works for 10 minutes and once, only the newest one works, and a user gets five tries every 10 minutes. It proves the inbox, not a second factor, so the session it opens has mfa: false.
POST https://auth.tilldev.dev/v1/signin
→ 200 { "mfa_required": true, "method": "email", "challenge_token": "…", "email_hint": "a***@acme.dev", "expires_at": "…" }
POST https://auth.tilldev.dev/v1/signin/email-code
{ "challenge_token": "…", "code": "482915" }
→ 200 { "ok": true, "user": { … }, "access_token": "…", "refresh_token": "…" }
POST https://auth.tilldev.dev/v1/signin
→ 403 { "error": "This sign-in looked unusual, so it was blocked. …", "code": "signin_blocked" }Google, GitHub and OIDC sign-ins return to your redirect with the same answers in the fragment: #mfa_required=1&method=email&challenge_token=…&email_hint=… or #error=signin_blocked. The hosted sign-in pages, @tillstack/auth-react 0.3 (useMfa().verifyEmailCode) and Till Authenticator handle both. Both emails can be edited on the app's Emails page, as Sign-in code and Sign-in refused.
When a refresh is refused
A refresh the policy refuses answers 401 with a code. Sending the same token again gets the same answer.
POST https://auth.tilldev.dev/v1/refresh
→ 401 { "error": "Session expired. Please sign in again.", "code": "reauth_required" }| code | Meaning |
|---|---|
reauth_required | This session ended; ask the user to sign in again. |
risk_revoked | Every session of the user ended. Consider telling them to check their account. |
With @tillstack/auth-react 0.3 or later, state.reason carries the code once the user is signed out, so you can say why. The OpenID Connect token endpoint answers invalid_grant, as the standard requires.
How fast an ended session stops
The refresh token stops at once. Access tokens of the session are refused at their next check by TillAuth's own endpoints, such as /v1/me and /userinfo. A backend that verifies access tokens itself, against the JWKS, accepts them until they expire, at most 10 minutes later. The same holds when you end a session from the dashboard, the CLI, a TillShield playbook or your identity provider.
Where it shows
- The app's Sessions page has a country and risk column and a Risky filter; a user's page shows the same for their sessions.
tilldev auth sessions my-app --riskylists sessions scoring 30 or more with their signals.- The Policies page counts risky sessions, sessions ended by the policy, sign-ins that owed an extra step and sign-ins blocked, over the last 7 days.
- The audit log records
signin.risk,signin.risk_step,signin.risk_blocked,signin.email_code.ok,signin.email_code.bad,session.risk,session.risk_reauthandsession.risk_revokedwith the score, signals, travel speed and country, andadmin.session_risk_changedfor policy changes. - The
signin.risk*,signin.email_code.okandsession.risk*events can be sent to your webhooks, and reach TillTell like every TillAuth event.
What it doesn't do
- It scores sign-ins and refreshes, not each request. Between refreshes, an access token works for up to 10 minutes.
- Country and travel come from the edge network in front of TillAuth. A request that arrives without that location is not scored on them, and a session started that way has no location to compare against.
- Locations are where the network is registered, not GPS. A VPN moves a user as far as its exit.
- If the address lists can't be read quickly, that signal is skipped rather than holding up the request.
- An emailed code is only as safe as the inbox. Users who matter should have an authenticator or a passkey; MFA enforcement can require one.
- A sign-in form you build yourself must handle
method: "email"andsignin_blocked; one that only knows TOTP will show the wrong prompt.
Related: Sessions · TOTP + backup codes · Audit log.