Secure and manage trusted login devices for Django users
Project description
๐ Django Trusted Devices
A plug-and-play Django app that adds trusted device management to your API authentication system using
djangorestframework-simplejwt. Automatically associates tokens with user devices, tracks login locations,
and enables per-device control over access and session management.
๐ Features
- ๐ JWT tokens include a unique
device_uid - ๐ Auto-detect IP, region, and city via configurable geolocation backend
- ๐ก๏ธ Per-device session tracking with update/delete restrictions
- ๐ Custom
TokenObtainPair,TokenRefresh, andTokenVerifyviews - ๐ช Logout & revoke โ logout current session or revoke all other devices
- ๐งผ Automatic cleanup of stale devices on login + management command
- ๐ท๏ธ Device naming โ let users label their devices ("Work Laptop", "iPhone")
- ๐
is_currentflag โ identify which device is making the request - ๐จ Suspicious login detection โ signals when a login comes from a new country
- ๐ต๏ธ Concurrent-session hijack detection โ same
device_uidfrom a new IP inside a short window invalidates both sessions - ๐ Rate limiting on login to prevent brute-force and device-creation spam
- ๐ Max device limit โ configurable cap with automatic oldest-device eviction (race-safe under concurrent logins)
- โ ๏ธ Custom exception classes โ catchable, typed errors with stable error codes (and optional handler that surfaces
codein JSON) - ๐ Full OpenAPI/Swagger schema via drf-spectacular
- ๐งฉ API-ready โ supports DRF out of the box
- โ๏ธ Fully customizable via
TRUSTED_DEVICEDjango settings - ๐ฐ๏ธ Trusted-proxy aware
X-Forwarded-Forparsing (off by default โ opt in once behind a known reverse proxy) - ๐ Cached geolocation โ configurable per-IP TTL keeps login latency low
- ๐ซ Rejects refresh/verify from unknown or cross-user devices
๐ฆ Installation
pip install django-trusted-device
Add to your INSTALLED_APPS:
INSTALLED_APPS = [
...
'trusted_devices',
'rest_framework_simplejwt.token_blacklist', # optional, for token rotation
]
Run migrations:
python manage.py migrate
โ๏ธ Configuration
Customize behavior in settings.py:
TRUSTED_DEVICE = {
"DELETE_DELAY_MINUTES": 60 * 24, # 24 hours before a device can be deleted
"UPDATE_DELAY_MINUTES": 60, # 1 hour before a device can be edited
"ALLOW_GLOBAL_DELETE": True, # Enable/disable device deletion globally
"ALLOW_GLOBAL_UPDATE": True, # Enable/disable device editing globally
"MAX_DEVICES_PER_USER": None, # None = unlimited, or set e.g. 5
"GEOLOCATION_BACKEND": "trusted_devices.utils.get_location_data",
"GEOLOCATION_CACHE_SECONDS": 60 * 60 * 24, # 24h cache; 0 disables
"DEFAULT_CAN_UPDATE_OTHER_DEVICES": True, # Default perm for new devices
"DEFAULT_CAN_DELETE_OTHER_DEVICES": True, # Default perm for new devices
# Trusted-proxy parsing โ enable only when behind a known reverse proxy.
"USE_X_FORWARDED_FOR": False,
"TRUSTED_PROXY_DEPTH": 1,
# Concurrent-session hijack detection.
"DETECT_CONCURRENT_SESSIONS": True,
"CONCURRENT_SESSION_WINDOW_SECONDS": 60,
}
Settings Reference
| Setting | Default | Description |
|---|---|---|
DELETE_DELAY_MINUTES |
1440 (24h) |
Minimum device age before it can be deleted |
UPDATE_DELAY_MINUTES |
60 (1h) |
Minimum device age before it can be edited |
ALLOW_GLOBAL_DELETE |
True |
Master switch for device deletion |
ALLOW_GLOBAL_UPDATE |
True |
Master switch for device editing |
MAX_DEVICES_PER_USER |
None |
Max active devices per user. Oldest evicted on new login (race-safe) |
GEOLOCATION_BACKEND |
"trusted_devices.utils.get_location_data" |
Dotted path to geolocation function |
GEOLOCATION_CACHE_SECONDS |
86400 |
Per-IP cache TTL for geolocation lookups. 0 disables caching |
DEFAULT_CAN_UPDATE_OTHER_DEVICES |
True |
Default update permission for newly created devices |
DEFAULT_CAN_DELETE_OTHER_DEVICES |
True |
Default delete permission for newly created devices |
USE_X_FORWARDED_FOR |
False |
When True, parse X-Forwarded-For to determine client IP. Leave False if the app is exposed directly โ clients can otherwise spoof their own IP |
TRUSTED_PROXY_DEPTH |
1 |
Number of trusted reverse proxies between the client and the app. The client IP is taken at position -(depth+1) from the right of X-Forwarded-For, so spoofed leftmost entries are ignored |
DETECT_CONCURRENT_SESSIONS |
True |
If a token's device_uid is presented from a different IP within the window below, the device is deleted (kicking both sessions) and device_compromised is fired |
CONCURRENT_SESSION_WINDOW_SECONDS |
60 |
Time window inside which a same-token IP change is treated as a hijack rather than a legitimate roaming user |
๐งฉ Usage
๐ SimpleJWT Configuration
Replace default SimpleJWT serializers with TrustedDevice serializers:
from datetime import timedelta
REST_FRAMEWORK = {
'DEFAULT_AUTHENTICATION_CLASSES': (
'trusted_devices.authentication.TrustedDeviceAuthentication',
),
}
SIMPLE_JWT = {
"ACCESS_TOKEN_LIFETIME": timedelta(minutes=60),
"REFRESH_TOKEN_LIFETIME": timedelta(days=30),
"AUTH_HEADER_TYPES": ("Bearer",),
"TOKEN_OBTAIN_SERIALIZER": 'trusted_devices.serializers.TrustedDeviceTokenObtainPairSerializer',
"TOKEN_REFRESH_SERIALIZER": 'trusted_devices.serializers.TrustedDeviceTokenRefreshSerializer',
"TOKEN_VERIFY_SERIALIZER": 'trusted_devices.serializers.TrustedDeviceTokenVerifySerializer',
}
๐ Custom Token Views
Replace the default SimpleJWT views:
from trusted_devices.views import (
TrustedDeviceTokenObtainPairView,
TrustedDeviceTokenRefreshView,
TrustedDeviceTokenVerifyView,
)
urlpatterns = [
path('api/token/', TrustedDeviceTokenObtainPairView.as_view(), name='token_obtain_pair'),
path('api/token/refresh/', TrustedDeviceTokenRefreshView.as_view(), name='token_refresh'),
path('api/token/verify/', TrustedDeviceTokenVerifyView.as_view(), name='token_verify'),
]
๐ก Device Management API
Use the provided TrustedDeviceViewSet:
from trusted_devices.views import TrustedDeviceViewSet
router.register(r'trusted-devices', TrustedDeviceViewSet, basename='trusted-device')
Endpoints
| Method | Endpoint | Description |
|---|---|---|
GET |
/trusted-devices |
List all devices (includes is_current flag) |
PATCH |
/trusted-devices/{device_uid} |
Update device name & permissions |
DELETE |
/trusted-devices/{device_uid} |
Delete a specific device session |
POST |
/trusted-devices/logout |
Revoke current device session |
POST |
/trusted-devices/revoke-all |
Revoke all other device sessions |
Example: List Devices Response
[
{
"device_uid": "a1b2c3d4-...",
"name": "Work Laptop",
"user_agent": "Mozilla/5.0 ...",
"ip_address": "203.0.113.42",
"country": "United States",
"region": "California",
"city": "San Francisco",
"last_seen": "2026-03-22T10:30:00Z",
"created_at": "2026-03-15T08:00:00Z",
"is_current": true
},
{
"device_uid": "e5f6g7h8-...",
"name": "",
"user_agent": "okhttp/4.12.0",
"ip_address": "198.51.100.7",
"country": "Germany",
"region": "Berlin",
"city": "Berlin",
"last_seen": "2026-03-20T14:22:00Z",
"created_at": "2026-03-10T09:15:00Z",
"is_current": false
}
]
Example: Revoke All Response
{
"revoked_count": 3
}
๐จ Signals
Connect to device lifecycle events:
from django.dispatch import receiver
from trusted_devices.signals import (
device_created,
device_revoked,
suspicious_login,
device_compromised,
)
@receiver(device_created)
def on_new_device(sender, user, device, **kwargs):
"""Fired when a new device is registered (login)."""
send_notification(user, f"New login from {device.city}, {device.country}")
@receiver(device_revoked)
def on_device_removed(sender, user, device_uid, **kwargs):
"""Fired when a device is deleted."""
log_audit(user, f"Device {device_uid} revoked")
@receiver(suspicious_login)
def on_suspicious_login(sender, user, device, previous_countries, **kwargs):
"""Fired when a login comes from a country not seen before."""
send_email(
user,
f"New login from {device.country} โ was this you? "
f"Your previous logins were from: {', '.join(previous_countries)}"
)
@receiver(device_compromised)
def on_device_compromised(sender, device_uid, previous_ip, current_ip, **kwargs):
"""
Fired when a token is presented from a new IP within
CONCURRENT_SESSION_WINDOW_SECONDS โ the device record has already been
deleted by the time this signal fires. The `user` kwarg is provided
when the request reached the auth layer; on refresh-time detection,
`user_id` is provided instead.
"""
user = kwargs.get("user")
user_id = kwargs.get("user_id") or (user.pk if user else None)
alert_security_team(
user_id=user_id,
message=f"Possible token theft for device {device_uid}: "
f"{previous_ip} โ {current_ip}",
)
| Signal | Args | When |
|---|---|---|
device_created |
user, device |
New device registered on login |
device_revoked |
user, device_uid |
Device deleted (via API, cleanup, or eviction) |
suspicious_login |
user, device, previous_countries |
Login from a new country |
device_compromised |
user or user_id, device_uid, previous_ip, current_ip |
Same device_uid used from a new IP inside CONCURRENT_SESSION_WINDOW_SECONDS. The device has already been deleted; both sessions are now invalid |
โ ๏ธ Exception Classes
All exceptions are importable from trusted_devices.exceptions:
| Exception | HTTP | Code | When |
|---|---|---|---|
DeviceUIDMissing |
401 | device_uid_missing |
Token has no device_uid claim |
DeviceNotRecognized |
401 | device_not_recognized |
Device deleted, never existed, or belongs to a different user than the token claims |
DeviceCompromised |
401 | device_compromised |
Same device_uid used from a new IP inside CONCURRENT_SESSION_WINDOW_SECONDS; session has been invalidated |
InactiveAccount |
401 | inactive_account |
User disabled after token issued |
TokenBlacklisted |
400 | token_blacklisted |
Rotated token reuse attempt |
DeviceNotVerified |
403 | device_not_verified |
No current device on request |
DeviceDeletionDisabled |
403 | device_deletion_disabled |
Global deletion turned off |
DeviceEditingDisabled |
403 | device_editing_disabled |
Global editing turned off |
DeviceLacksDeletePermission |
403 | device_lacks_delete_permission |
Device can't delete others |
DeviceLacksEditPermission |
403 | device_lacks_edit_permission |
Device can't edit others |
DeviceSessionTooRecent |
403 | device_session_too_recent |
Target within delay window |
DeviceSelfModification |
403 | device_self_modification |
Attempt to modify/delete own device (use /logout) |
DevicePermissionEscalation |
403 | device_permission_escalation |
Granting permissions the current device doesn't have |
InvalidGeolocationBackend |
โ | โ | Misconfigured GEOLOCATION_BACKEND (startup error) |
from trusted_devices.exceptions import DeviceNotRecognized
try:
# ... authenticate
except DeviceNotRecognized:
# handle specifically instead of catching generic AuthenticationFailed
pass
Surfacing code in JSON responses (optional)
DRF's default exception handler emits only detail in error bodies. To
include the stable code alongside it โ recommended for clients that
branch on machine-readable error identifiers โ wire up the bundled
handler:
REST_FRAMEWORK = {
# ...
"EXCEPTION_HANDLER": "trusted_devices.handlers.trusted_device_exception_handler",
}
Responses then look like:
{
"detail": "This session device is no longer valid.",
"code": "device_not_recognized"
}
๐ Custom Geolocation Backend
By default, geolocation uses ipapi.co. You can replace it with any provider (MaxMind GeoIP2, ip-api.com, etc.):
# settings.py
TRUSTED_DEVICE = {
"GEOLOCATION_BACKEND": "myapp.geo.maxmind_lookup",
}
Your backend must accept an IP string and return a dict with optional keys country, region, city:
# myapp/geo.py
from trusted_devices.utils import LocationData
def maxmind_lookup(ip: str) -> LocationData:
import geoip2.database
reader = geoip2.database.Reader('/path/to/GeoLite2-City.mmdb')
try:
response = reader.city(ip)
return {
"country": response.country.name,
"region": response.subdivisions.most_specific.name,
"city": response.city.name,
}
except geoip2.errors.AddressNotFoundError:
return {}
The backend's return value is automatically validated โ unexpected keys are logged as warnings, non-dict returns are safely ignored.
Geolocation results are cached per-IP for GEOLOCATION_CACHE_SECONDS (default 24h). Repeat lookups don't hit the backend, so login latency stays low and external API quota isn't spent re-resolving the same client. Set GEOLOCATION_CACHE_SECONDS = 0 to disable.
๐ฐ๏ธ Trusted Proxies & Client IP
By default the library uses REMOTE_ADDR as the client IP. The X-Forwarded-For header is ignored unless you opt in โ otherwise a client connecting directly to the app could spoof their IP and country by setting the header themselves.
When the app runs behind a known reverse proxy (Cloudflare, AWS ALB, nginx, etc.), enable parsing and tell the library how many trusted hops sit between the public client and Django:
TRUSTED_DEVICE = {
"USE_X_FORWARDED_FOR": True,
"TRUSTED_PROXY_DEPTH": 1, # 1 if you have one proxy, 2 if proxy โ load balancer, etc.
}
The client IP is read at position -(TRUSTED_PROXY_DEPTH + 1) from the right of X-Forwarded-For, which is the last entry an attacker cannot inject.
๐ต๏ธ Concurrent-Session Hijack Detection
If a stolen access or refresh token is replayed from a different IP while the legitimate user is still active, the library treats it as a session hijack:
- On every authenticated request, the incoming IP is compared against the device's stored
last_ip. - If they differ and
last_seenis withinCONCURRENT_SESSION_WINDOW_SECONDS(default60), the device record is deleted. - Both sessions immediately fail authentication on their next request (
DeviceCompromised, codedevice_compromised). - The
device_compromisedsignal fires so you can alert the user, revoke related sessions, or escalate to a security team.
Detection is on by default and respects USE_X_FORWARDED_FOR for IP attribution. To disable (e.g. if you have a roaming-heavy mobile audience), set:
TRUSTED_DEVICE = {
"DETECT_CONCURRENT_SESSIONS": False,
}
โน๏ธ Tune
CONCURRENT_SESSION_WINDOW_SECONDScarefully. A short window catches active token-replay; a long window will cause false positives for users on flaky mobile networks where IPs change between requests.
๐งน Device Cleanup
Automatic (on login)
Stale devices (not seen within REFRESH_TOKEN_LIFETIME) are automatically cleaned up each time a user logs in.
Management Command
# Delete devices not seen within the refresh token lifetime
python manage.py cleanup_devices
# Override with a custom cutoff (30 days)
python manage.py cleanup_devices --days 30
# Preview without deleting
python manage.py cleanup_devices --dry-run
Add to crontab for scheduled cleanup:
# Run daily at 3am
0 3 * * * python manage.py cleanup_devices
๐ค Device Model
Each trusted device includes:
| Field | Purpose |
|---|---|
device_uid |
UUID primary key |
name |
User-defined label (e.g. "Work Laptop") |
user_agent |
Browser or app string |
ip_address |
Client IP captured when the device was first registered |
last_ip |
Most recently observed IP โ compared against incoming requests for hijack detection |
country / region / city |
Geolocation data |
last_seen / created_at |
Activity timestamps |
can_update_other_devices |
Permission flag |
can_delete_other_devices |
Permission flag |
๐ง How It Works
- Login โ a
device_uidis generated and embedded in the JWT token. ATrustedDevicerecord is created (transactionally, with row-level locking on the user whenMAX_DEVICES_PER_USERis set) with IP, user agent, and geolocation. - Every API request โ
TrustedDeviceAuthenticationvalidates thedevice_uidbelongs to the token's user, runs hijack detection againstlast_ip, then updateslast_seen+last_ip. - Token refresh โ validates the device still exists for this user, runs hijack detection, updates timestamps, and optionally rotates the token.
- Device management โ users can list, rename, update permissions, or revoke their devices via the API.
- Session revocation โ deleting a device record immediately blocks all requests using tokens linked to that device, even if the JWT hasn't expired.
- Hijack invalidation โ if the same
device_uidappears from a new IP within the configured window, the device record is deleted on the spot โ both sessions fail their next call withDeviceCompromised.
๐งช Testing Locally
# Create and activate a virtual environment
uv venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
# Install the package in editable mode with dev extras
uv pip install -e ".[dev]"
# Run the test suite
pytest
๐งฑ Dependencies
- Django >= 4.2
- Django REST Framework >= 3.14.0
- djangorestframework-simplejwt >= 5.5.0
- drf-spectacular >= 0.28.0
- httpx >= 0.28.1 (for default geolocation backend)
๐ค Collaboration & Contributing
We love community contributions! To collaborate:
-
Fork the repo and create a feature branch:
git checkout -b feature/my-amazing-idea
-
Follow code style โ run:
make lint # runs flake8, isort, black
-
Write & run tests:
pytest
-
Commit with clear messages and open a Pull Request. GitHub Actions will lint + test your branch automatically.
๐ฃ๏ธ Discussions & Issues
- ๐ก Questions / ideas โ GitHub Discussions
- ๐ Bugs / feature requests โ GitHub Issues
๐ Maintainer Workflow
- PRs require at least one approval and passing CI
- We squashโmerge to keep history clean
- Follows Semantic Versioning (
MAJOR.MINOR.PATCH), tagged asvX.Y.Z
๐ License
Made with โค๏ธ by Jahongir Ganiev Security questions or commercial support? Open an issue or email contact@jakhongir.dev
Project details
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file django_trusted_devices-1.5.tar.gz.
File metadata
- Download URL: django_trusted_devices-1.5.tar.gz
- Upload date:
- Size: 30.9 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.12.13
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
a46918e099309a89b35e6dc0363b183f1983d9102bd398c6791752b6137c1d07
|
|
| MD5 |
12e5bf0698eadbec8be87bcb5278b0c6
|
|
| BLAKE2b-256 |
6dac47528cb8bc93d04028c3f0fdb7dade5b02cfe2df4dc5b0d0ef3d38521707
|
File details
Details for the file django_trusted_devices-1.5-py3-none-any.whl.
File metadata
- Download URL: django_trusted_devices-1.5-py3-none-any.whl
- Upload date:
- Size: 31.4 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.12.13
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
a0f01f276c14ddd86541d7b7242efe3134d8ec4e7e3e5e51e5b55e7d3d18fd6f
|
|
| MD5 |
22e45316af16ec5555ad63ff6cd79ab6
|
|
| BLAKE2b-256 |
60a212427df50ad5e19e90402f9df8a9a06caebd5a2bc5d486bf1b40cea5592a
|