check_vpn_ip), and a handful of endpoints use token/signature auth (drone-activation header, video-room token, Stripe-Signature). See HTTP API Overview & Auth Models for the map, and Authentication & Security Model for the platform-wide picture.
All endpoints below live under the blueprint prefix
/api/v1 (src/main.py:222). Production base URL is https://api.skyhub.ai/api/v1; the dev environment is https://api.dev.skyhub.ai/api/v1. The auth blueprint is defined in src/routes/auth_routes.py.Token model
Two token types are issued, both signed withJWT_SECRET_KEY using HS256. Expiry and algorithm are configured in src/main.py:245:
src/main.py:245
| Token | Lifetime | Purpose | How it is presented |
|---|---|---|---|
| access | 10 minutes | Authorizes every @jwt_required() HTTP route and the Socket.IO handshake | Authorization: Bearer <access> header (or ?token=<jwt> query param on Socket.IO) |
| refresh | 12 hours | Mints a fresh access token when the access token expires | Authorization: Bearer <refresh> header, only accepted by GET /auth/refresh |
identity embedded in a login token is the numeric user.id (create_access_token(identity=user.id), auth_routes.py:134), which flask-jwt-extended stores in the standard sub claim. A minted token therefore carries sub (user id), jti (unique id used for revocation), type (access/refresh), exp, and — only after account activation — a custom level claim (see below).
Endpoint reference
| Method & path | Auth | Body | Success |
|---|---|---|---|
POST /login | public | {username, password} | 200 {access_token, refresh_token} |
GET /auth | access | — | 200 {message: "valid token"} |
GET /auth/refresh | refresh | — | 200 {access_token, refresh_token} |
DELETE /logout | access | — | 200 {message: "Successfully logged out"} |
POST /verify-email | public* | {email} | 200 {success, data:{message}} |
POST /register/<token> | verify token | {password} | 200 {success, data:{message}} |
POST /activate/<int:user_id>/<string:token> | activation token | — | 200 {access_token, refresh_token} (with level) |
POST /reset-password | public | {email} | 200 {success, data:{message}} |
POST /update-password | access | {password} | 200 {success, data} |
* /verify-email and /register/<token> are gated by the ENABLE_REGISTRATION environment variable (default true, settings.py:7). When it is false both return 403 {error: "Registration is disabled"}.Login → use → refresh → logout
Log in
get_user_by_email(username) loads the user, then bcrypt.check_password_hash(user.password, password) verifies the credentials (auth_routes.py:129). The username field is the user’s email address.200 OK
401 with a distinctive body shape — jsonify({"error": "Please check the credentials"}, 401) serializes the tuple, so the body is a two-element JSON array [{"error":"..."}, 401], not a plain object. Clients should key off the HTTP status, not the body shape.Call authorized endpoints
Attach the access token to every request. The Dashboard’s
AuthInterceptor does this automatically.GET /auth is a cheap way to test whether an access token is still valid (200 {"message":"valid token"} vs 401).Refresh before the 10-minute access token expires
GET /auth/refresh is protected by @jwt_required(refresh=True) — it only accepts a refresh token. It re-issues both a new access and a new refresh token, keyed on get_jwt_identity():200 OK
Log out
DELETE /logout adds the presented token’s jti to the in-memory BLOCKLIST set:The level claim (activation only)
Users carry a level (UserLevel.ADMIN or UserLevel.CUSTOMER, src/models/user.py). The claim is injected into the token only by the activation route, which reads the enum name:
src/routes/auth_routes.py:335
/login and /auth/refresh do not set additional_claims, level is present only on the pair of tokens returned by /activate, and disappears after the next login or refresh. Note also that level is nullable (default None); activating a user whose level was never set will raise on user.level.name.
Registration & activation
Two independent onboarding paths exist. Both funnel throughUserService (src/service/user_service.py).
Self-service registration (email-verified)
Request a verification email
POST /verify-email {email} validates the address against a regex, rejects an already-existing user (400), then calls user_service.send_verify_email. That serializes {email, origin} with an itsdangerous.URLSafeTimedSerializer and emails a link of the form {origin}/reset-password?token=<token>&verify=true (user_service.py:87). The token is valid for 1 hour (max_age=3600).Complete registration
The frontend collects a password and calls
POST /register/<token> {password}. create_user runs serializer.loads(token, salt=secret_key, max_age=3600), allocates a user VPN IP from the user CIDR (IPService.get_user_ip()), and creates the account with is_active=True (user_service.py:94). It does not return tokens — the client logs in afterward via /login.Admin/invite activation
The/activate/<user_id>/<token> route completes accounts created out-of-band. It matches the path token against the stored User.activation_token, flips is_active = True, clears the token, and returns a token pair carrying the level claim (see above). An invalid user_id/token pair returns 401 {error:"Invalid token"}.
This user
activation_token is unrelated to the 10-digit drone activation token used by GET /drone/activate. That is a device-bootstrap flow, documented in Drone Management & Control Actions.Password reset
Reset reuses a short-lived access token as the reset token, not theitsdangerous serializer:
Request the reset email
POST /reset-password {email} looks the user up by username == email and emails a link {origin}/reset-password?token=<jwt> where <jwt> is create_access_token(email) — a normal 10-minute access token whose identity is the email (user_service.py:70). If no user matches, the route surfaces 400/500.Token revocation (BLOCKLIST)
Revocation is enforced by atoken_in_blocklist_loader callback that consults a plain in-memory set():
src/routes/auth_routes.py:17
src/main.py:251
DELETE /logout adds the presented token’s jti to this set; every @jwt_required() HTTP request thereafter is rejected with 401 if its jti is a member. This is simple and fast, but has three limitations a future editor must keep in mind — they directly explain most “I logged out but the token still works” reports:
Socket.IO bypasses the blocklist entirely
Socket.IO bypasses the blocklist entirely
The Socket.IO handshake decodes the JWT manually with PyJWT (
jwt.decode(token, jwt_secret_key, algorithms=["HS256"]), socket_routes.py) and never consults BLOCKLIST. A logged-out token is still accepted for telemetry subscriptions until it expires. See Socket.IO Telemetry Streaming.Logout revokes only the access token, not the refresh token
Logout revokes only the access token, not the refresh token
/logout is @jwt_required() (access-token protected) and blocklists just the jti it was called with — the access token. The refresh token is never revoked, so a client (or attacker) holding it can immediately mint a brand-new access token via /auth/refresh. To fully sign a session out you must also discard the refresh token client-side and (ideally) blocklist it.Related pages
HTTP API Overview & Auth Models
The three coexisting auth models and the response-envelope conventions.
Dashboard Auth
How the Angular client stores tokens and wires the AuthInterceptor / guard.
Socket.IO Telemetry
The
?token= handshake that reuses these access tokens (and skips the blocklist).Auth & Security Model
Platform-wide view across JWT, VPN-IP, and token/signature auth.

