The Gateway authenticates the Dashboard UI (and any first-party HTTP client) with stateless HS256 JSON Web Tokens issued by flask-jwt-extended. This page covers the token lifecycle end to end: how you obtain, use, refresh, and revoke tokens, plus the registration, account-activation, and password-reset flows that surround it. This is only one of three auth models on the Gateway. Drone and gamepad callbacks authenticate by VPN source IP (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 with JWT_SECRET_KEY using HS256. Expiry and algorithm are configured in src/main.py:245:
src/main.py:245
app.config["JWT_SECRET_KEY"] = settings.JWT_SECRET_KEY
app.config["JWT_ALGORITHM"] = "HS256"
app.config["JWT_ACCESS_TOKEN_EXPIRES"] = timedelta(minutes=10)
app.config["JWT_REFRESH_TOKEN_EXPIRES"] = timedelta(hours=12)
TokenLifetimePurposeHow it is presented
access10 minutesAuthorizes every @jwt_required() HTTP route and the Socket.IO handshakeAuthorization: Bearer <access> header (or ?token=<jwt> query param on Socket.IO)
refresh12 hoursMints a fresh access token when the access token expiresAuthorization: Bearer <refresh> header, only accepted by GET /auth/refresh
The 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).
JWT_SECRET_KEY is the single shared secret for both the HTTP JWT layer and the manual Socket.IO handshake decode (socket_routes.py reads settings.JWT_SECRET_KEY directly). In any non-local deployment it is a required variable — startup fails fast if it is missing (validate_critical_config in src/main.py:137). Never commit or log it. See Environment Variables.

Endpoint reference

Method & pathAuthBodySuccess
POST /loginpublic{username, password}200 {access_token, refresh_token}
GET /authaccess200 {message: "valid token"}
GET /auth/refreshrefresh200 {access_token, refresh_token}
DELETE /logoutaccess200 {message: "Successfully logged out"}
POST /verify-emailpublic*{email}200 {success, data:{message}}
POST /register/<token>verify token{password}200 {success, data:{message}}
POST /activate/<int:user_id>/<string:token>activation token200 {access_token, refresh_token} (with level)
POST /reset-passwordpublic{email}200 {success, data:{message}}
POST /update-passwordaccess{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"}.
Response envelopes are inconsistent across this blueprint. The token-issuing routes (/login, /auth, /auth/refresh, /logout, /activate) return bare jsonify bodies with top-level fields, while the email-driven routes (/verify-email, /register, /reset-password, /update-password) use the get_success_response/get_error_response envelope ({success, data} / {success, error:{code, message}}) from src/utils/common_helper.py. A client must handle both shapes; do not “normalize” them without also updating the Dashboard’s AuthService.

Login → use → refresh → logout

1

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.
curl -X POST https://api.skyhub.ai/api/v1/login \
  -H "Content-Type: application/json" \
  -d '{"username":"[email protected]","password":"<redacted>"}'
200 OK
{ "access_token": "eyJhbGciOiJIUzI1NiIs...", "refresh_token": "eyJhbGciOiJIUzI1NiIs..." }
On bad credentials the route returns 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.
2

Call authorized endpoints

Attach the access token to every request. The Dashboard’s AuthInterceptor does this automatically.
curl https://api.skyhub.ai/api/v1/drones \
  -H "Authorization: Bearer <access_token>"
GET /auth is a cheap way to test whether an access token is still valid (200 {"message":"valid token"} vs 401).
3

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():
curl https://api.skyhub.ai/api/v1/auth/refresh \
  -H "Authorization: Bearer <refresh_token>"
200 OK
{ "access_token": "<new access>", "refresh_token": "<new refresh>" }
Refresh does not carry the level claim forward — it calls create_access_token(identity=current_user) with no additional_claims (auth_routes.py:76). Any level value present on the activation token is dropped the first time the client refreshes. Do not rely on level for authorization decisions across a token’s whole lifetime.
4

Log out

DELETE /logout adds the presented token’s jti to the in-memory BLOCKLIST set:
curl -X DELETE https://api.skyhub.ai/api/v1/logout \
  -H "Authorization: Bearer <access_token>"
Read the revocation limitations before you rely on this for security.

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
additional_claims = {
    "level": user.level.name,
}
return jsonify({
    "access_token": create_access_token(identity=user.id, additional_claims=additional_claims),
    "refresh_token": create_refresh_token(identity=user.id, additional_claims=additional_claims),
}), 200
Because /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 through UserService (src/service/user_service.py).

Self-service registration (email-verified)

1

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).
2

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 the itsdangerous serializer:
1

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.
2

Set the new password

The frontend calls POST /update-password {password} with that token as the Bearer credential. The route reads get_jwt_identity() and passes it to update_password, which looks the user up by username == identity (user_service.py:79).
update-password only works when the token identity is an email/username, because it looks up User.username. The reset token satisfies this (identity = email). A token obtained from /login has identity = numeric user.id, so calling /update-password with an ordinary logged-in access token finds no matching username and fails with 500 "Unable to update password". This endpoint is effectively the reset-completion step, not a general “change my password while logged in” endpoint.

Token revocation (BLOCKLIST)

Revocation is enforced by a token_in_blocklist_loader callback that consults a plain in-memory set():
src/routes/auth_routes.py:17
# TODO Make this blocklist persistent in DB
BLOCKLIST = set()
src/main.py:251
@jwt.token_in_blocklist_loader
def check_if_token_revoked(jwt_header, jwt_payload):
    jti = jwt_payload["jti"]
    return jti in BLOCKLIST
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:
BLOCKLIST is an in-memory Python set, so it is wiped on every restart/redeploy — silently un-revoking every previously logged-out token. Today the Gateway runs a single gunicorn worker in a single task (gunicorn --workers 1 --threads 8, ECS desired_count = 1), so all threads share this one set and a /logout is visible process-wide. But because the set is per-process, revocation would not be shared the moment the service is scaled beyond one worker or one task. Making revocation both durable and scale-safe requires a shared store (e.g. Redis / DB) — the TODO on line 17 flags exactly this.
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 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.
Because revocation is best-effort, treat the 10-minute access-token expiry as the real security boundary. Keeping that window short is deliberate — do not lengthen JWT_ACCESS_TOKEN_EXPIRES without first making the blocklist durable (surviving restarts) and shared-store backed so it stays correct if the service is scaled out.

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.