| Model | Who uses it | How it authenticates | Failure code | Enforced by |
|---|---|---|---|---|
| JWT Bearer | The Dashboard (human users) | HS256 access token in Authorization: Bearer <jwt> | 401 | flask-jwt-extended @jwt_required() |
| VPN source-IP trust | Physical drones & the on-drone gamepad service | Request source IP in the WireGuard drone subnet (10.71.0.0/16) | 400 | check_vpn_ip decorator (src/middleware/drone_vpn.py) |
| Token / signature | Drone activation, video nodes, Stripe | 10-digit token header, video-room bearer token, or Stripe-Signature | 406 / 402 / 400 | Per-route manual checks |
For where these callers physically sit on the network — the WireGuard planes, the jumphost, and the public ingress that terminates TLS in front of the Gateway — see Network & VPN Topology. This page is about who is trusted and how, not where they connect from.
JWT Bearer (the UI model)
Every human-facing endpoint is guarded byflask-jwt-extended’s @jwt_required(). The Dashboard obtains a token pair from POST /api/v1/login and attaches the access token as Authorization: Bearer <jwt> on all subsequent HTTP calls (and as a ?token= query param on the Socket.IO handshake).
Tokens, claims and lifetimes
Signing and lifetimes are configured centrally insrc/main.py:245-248:
src/main.py
| Property | Value | Source |
|---|---|---|
| Algorithm | HS256 (symmetric) | main.py:246 |
| Access token TTL | 10 minutes | main.py:247 (hardcoded) |
| Refresh token TTL | 12 hours | main.py:248 (hardcoded) |
Identity claim (sub) | user.id | create_access_token(identity=user.id) |
Optional level claim | user role name, added only on activation | auth_routes.py:335-343 |
level claim is added by POST /activate/<user_id>/<token> via additional_claims, but the refresh route reissues a bare access token with no additional claims (create_access_token(identity=current_user), auth_routes.py:76). The level claim therefore silently disappears after the first token refresh — do not build authorization decisions on it without accounting for this.
The login / refresh / logout lifecycle
Login
POST /api/v1/login with {username, password}. username is the email. On success it returns {access_token, refresh_token} (200). On failure it returns jsonify({"error": ...}, 401) — note the 401 is serialized into the JSON body as a tuple element, so the error body shape differs from the success path (auth_routes.py:140).Use the access token
Send
Authorization: Bearer <access_token> on every request. @jwt_required() returns 401 for missing, expired, or revoked tokens.Refresh before expiry
GET /api/v1/auth/refresh guarded by @jwt_required(refresh=True) — it requires the refresh token, not the access token. Returns a fresh access + refresh pair. The Dashboard also refreshes proactively on a timer and on any 401.Token revocation is best-effort only
VPN source-IP trust (the drone-callback model)
Physical drones and the on-drone gamepad service call back into the Gateway to report executions, upload assets, and fetch update credentials. These callers have no user JWT. Instead they are trusted because of where the request came from: the WireGuard drone plane10.71.0.0/16. This is implemented by the check_vpn_ip decorator.
The decorator resolves the drone’s identity in this order (src/middleware/drone_vpn.py:32-77): remote_addr, then the X-Real-IP header, then X-Forwarded-For, accepting the first that starts with 10.71.. As a non-VPN fallback it accepts an X-Drone-IP header if the value is a SKYHUB_SITL_* container name (whenever ENABLE_SITL) or one of the whitelisted local prefixes (only when IS_LOCAL_ENVIRONMENT). On success it sets request.vpn_ip; otherwise it returns 400. Each route then maps that IP back to a drone with get_drone_by_ip(request.vpn_ip).
Endpoints guarded by check_vpn_ip:
| Endpoint | Purpose |
|---|---|
GET /api/v1/drone/pull | Drone self-update — returns temporary AWS STS credentials |
POST /api/v1/executions/start | Start execution tracking on ARM |
POST /api/v1/executions/<id>/complete | Complete execution on land/disarm |
POST /api/v1/executions/<id>/log | Attach uploaded .bin flight log |
GET /api/v1/executions/current | In-progress execution for the calling drone |
POST /api/v1/authenticate_upload | Presigned S3 upload URL + asset row |
POST /api/v1/complete_upload | Mark asset upload complete |
_ALLOWED_LOCAL_IP_PREFIXES) are 127., ::1, 172.17.–172.20. (Docker bridges), 192.168., 10.223. (the mock-VPN network), and the SKYHUB_SITL_ container-name prefix.
Minor footgun for editors: the middleware package’s init file is misnamed
___init__.py (three underscores) in src/middleware/, so it is not a real package __init__. Imports work only because drone_vpn is imported by module path.Token / signature auth (activation, video, Stripe)
Three endpoints authenticate with a shared secret rather than a JWT or a source IP.Activation token — GET /api/v1/drone/activate
Activation token — GET /api/v1/drone/activate
A freshly-flashed physical drone bootstraps itself by calling
GET /api/v1/drone/activate with a 10-digit numeric activation token in a token header. The handler (drone_routes.py:1527-1635) validates that the token is exactly 10 digits and matches a drone row’s activation_token, waits up to ~15 s for the drone’s VPN to come up, then returns ECR docker-login credentials, a presigned docker-compose URL from INSTALLER_BUCKET, and a VPN config link. It then clears the activation token (single-use). Invalid/missing token → 406; timeout or AWS failure → 500.Security notes: the token space is only 10 decimal digits and there is no rate-limiting in the handler, so brute-force resistance depends entirely on the token being cleared after first use and on the endpoint not being broadly reachable. This is the one token-auth endpoint reachable before the drone joins the VPN.Video-room token — POST /api/v1/video_room
Video-room token — POST /api/v1/video_room
The on-drone video node creates its Janus room by calling
POST /api/v1/video_room with Authorization: Bearer <video_room_token> — not a user JWT. The handler (video_room_routes.py:15-64) strips the Bearer prefix and matches the token against the drone’s stored video_room_token via get_drones_by_video_room_id_and_token(room_number, token). Missing Authorization header → 402 (an unusual use of Payment Required for an auth error — preserve it or the drone-side client breaks). The start / stop / restart video endpoints, by contrast, are ordinary @jwt_required() UI routes.Stripe-Signature — POST /api/v1/billing/webhook
Stripe-Signature — POST /api/v1/billing/webhook
Stripe posts subscription/invoice events to
POST /api/v1/billing/webhook, which is public (no JWT). Authenticity is established by verifying the Stripe-Signature header against STRIPE_WEBHOOK_SECRET. Verification lives in subscription_service.handle_webhook_event (subscription_service.py:238-250): it calls stripe.Webhook.construct_event(payload, sig_header, STRIPE_WEBHOOK_SECRET). A signature/payload failure raises ValueError → the route returns 400.Socket.IO handshake auth
The telemetry Socket.IO namespace authenticates independently offlask-jwt-extended. On connect, socket_routes.py:30-76 reads the JWT only from the ?token= query param, decodes it manually with jwt.decode(token, JWT_SECRET_KEY, algorithms=["HS256"]), and stores decoded_data["sub"] as session["user_id"]. An invalid/expired token emits error and disconnects.
See Real-time Transport Channels for how the telemetry, command (redispad), and video channels differ, and Cross-System Data Flows for the full command and telemetry paths.
CORS
CORS origins are hardcoded per environment inmain.py:188-210, selected by IS_LOCAL_ENVIRONMENT:
| Environment | HTTP CORS origins | Socket.IO cors_allowed_origins |
|---|---|---|
Local (DEPLOYMENT_ENVIRONMENT=local) | http://localhost:4200, http://localhost:*, https://dev.skyhub.ai, https://api.dev.skyhub.ai | * (wide open) |
| Non-local (server/prod) | https://skyhub.ai, https://api.skyhub.ai | same prod origins |
GET, POST, OPTIONS, DELETE, PUT, PATCH; allowed headers Content-Type, Authorization, Accept; supports_credentials=True.
Adding a new frontend origin is a code change, not an env var. Also note Socket.IO CORS is
* in local mode — never run a publicly-reachable deployment with DEPLOYMENT_ENVIRONMENT=local.Secrets & startup validation
The service fails fast rather than booting insecurely.JWT_SECRET_KEYis read insettings.py:112-123. On non-local deployments an empty value raisesRuntimeErrorat import time (beforevalidate_critical_configeven runs). Only whenIS_LOCAL_ENVIRONMENTdoes it fall back to the insecure literaltest-secret-key-for-development-only. The same key signs both HTTP JWTs and the Socket.IO handshake, so rotating it invalidates every session and open telemetry stream at once.validate_critical_config()(main.py:137-158) raisesRuntimeErrorifREGION,DB_IP, orJWT_SECRET_KEYare empty, and additionally requiresVPN_BUCKETwhen not local.- Other secrets to protect (all via env):
STRIPE_SECRET_KEY,STRIPE_WEBHOOK_SECRET,STRIPE_PUBLISHABLE_KEY, theDB_*credentials,MAIL_PASSWORD, andREDIS_PASSWORD/SITL_REDIS_PASSWORD. AWS access is via the task role /boto3default credential chain (ECR, S3, SSM, STS, EC2).
sk_live_xxx / pk_xxx / <redacted>. The full inventory of these variables lives in Gateway Environment Variables; the boot ordering is in Startup, Validation & Composition Root.
Choosing auth for a new endpoint
User-facing?
A human on the Dashboard triggers it →
@jwt_required(). Read the identity with get_jwt_identity() and always scope queries to that user (e.g. get_drone_by_id_and_user).Drone/gamepad callback?
The drone calls back over the VPN →
@check_vpn_ip, then get_drone_by_ip(request.vpn_ip). Never combine it with a public path that also accepts spoofable headers.Machine/webhook?
A third party or un-activated device → verify a token or signature explicitly (activation token, video-room token,
Stripe-Signature). Keep the secret in env and reject when it is unset in non-local envs.Security gotchas to preserve or fix deliberately
In-memory JWT blocklist
In-memory JWT blocklist
BLOCKLIST is per-process and non-persistent (auth_routes.py:17). Revocation does not survive restarts or span gunicorn workers, and the socket handshake ignores it entirely. A shared revocation store is the correct fix.Drone-scoped telemetry rooms
Drone-scoped telemetry rooms
subscribe_telemetry performs no ownership check; room names are drone_{id}_{stream}. Cross-user telemetry leakage is possible. Add an ownership check on subscribe if hardening — but keep the exact room-name string or telemetry stops flowing.Network-trust device auth
Network-trust device auth
check_vpn_ip trusts X-Forwarded-For / X-Real-IP / X-Drone-IP. Safe only while the Gateway is reachable exclusively through a proxy that overwrites those headers. SKYHUB_SITL_* via X-Drone-IP is trusted on any deployment with ENABLE_SITL.Unusual status codes
Unusual status codes
Missing video-room token → 402;
check_vpn_ip rejection → 400; drone-create over subscription limit → 402; invalid activation token → 406; login failure serializes 401 into the JSON body. Clients depend on these exact codes — change them only with matching client changes.Level claim dropped on refresh
Level claim dropped on refresh
The
level claim is only set at activation and is not re-added by /auth/refresh, so it vanishes after the first refresh. Do not rely on it for authorization.Related pages
HTTP API Overview & Auth Models
The full endpoint surface and response-envelope conventions.
Authentication & JWT Lifecycle
The login/refresh/logout/register/activate endpoints in detail.
VPN IP Auth & Jumphost Routing
How
check_vpn_ip and jumphost header routing work at the code level.Socket.IO Telemetry Streaming
The telemetry handshake, stream types, and room semantics.
Network & VPN Topology
Where each caller sits and how traffic reaches the Gateway.
Stripe Billing & Vehicle Limits
The webhook handler and subscription gating behind the 402s.

