The Gateway is the control-plane hub of SkyHub, so almost every trust decision in the platform is made here. There is no single auth framework: three distinct authentication models coexist, one per class of caller. Picking the wrong one for a new endpoint is the most common security mistake in this codebase, so the model is worth understanding before you add a route.
ModelWho uses itHow it authenticatesFailure codeEnforced by
JWT BearerThe Dashboard (human users)HS256 access token in Authorization: Bearer <jwt>401flask-jwt-extended @jwt_required()
VPN source-IP trustPhysical drones & the on-drone gamepad serviceRequest source IP in the WireGuard drone subnet (10.71.0.0/16)400check_vpn_ip decorator (src/middleware/drone_vpn.py)
Token / signatureDrone activation, video nodes, Stripe10-digit token header, video-room bearer token, or Stripe-Signature406 / 402 / 400Per-route manual checks
Socket.IO telemetry is a fourth surface layered on top of the JWT model but with its own, weaker, handshake (see Socket.IO handshake below).
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 by flask-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 in src/main.py:245-248:
src/main.py
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)
PropertyValueSource
AlgorithmHS256 (symmetric)main.py:246
Access token TTL10 minutesmain.py:247 (hardcoded)
Refresh token TTL12 hoursmain.py:248 (hardcoded)
Identity claim (sub)user.idcreate_access_token(identity=user.id)
Optional level claimuser role name, added only on activationauth_routes.py:335-343
The JWT_ACCESS_TOKEN_EXPIRES and JWT_REFRESH_TOKEN_EXPIRES values are hardcoded timedeltas in main.py, not environment variables — despite CLAUDE.md listing them as env-configurable. Changing token lifetimes requires a code change. Only JWT_SECRET_KEY is read from configuration.
The 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

1

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

Use the access token

Send Authorization: Bearer <access_token> on every request. @jwt_required() returns 401 for missing, expired, or revoked tokens.
3

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

Logout / revoke

DELETE /api/v1/logout adds the token’s jti to an in-process BLOCKLIST set (auth_routes.py:283). A token_in_blocklist_loader (main.py:251-254) rejects any token whose jti is in that set.

Token revocation is best-effort only

BLOCKLIST is an in-memory set() in auth_routes.py:17, explicitly marked # TODO Make this blocklist persistent in DB. Consequences a refactor must be aware of:
  • Revocation is lost on restart.
  • Under gunicorn’s multiple workers, a jti blocked on one worker is still valid on the others — logout does not reliably invalidate a token.
  • The Socket.IO handshake does not consult BLOCKLIST at all (it decodes the JWT manually), so a logged-out token still opens a telemetry stream until it expires.
Because access tokens live only 10 minutes, the practical exposure is bounded, but treat logout as “stop using the token” rather than “the token is now dead.” A production-grade fix needs a shared store (Redis/DB) consulted by both the HTTP blocklist loader and the socket handshake.

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 plane 10.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:
EndpointPurpose
GET /api/v1/drone/pullDrone self-update — returns temporary AWS STS credentials
POST /api/v1/executions/startStart execution tracking on ARM
POST /api/v1/executions/<id>/completeComplete execution on land/disarm
POST /api/v1/executions/<id>/logAttach uploaded .bin flight log
GET /api/v1/executions/currentIn-progress execution for the calling drone
POST /api/v1/authenticate_uploadPresigned S3 upload URL + asset row
POST /api/v1/complete_uploadMark asset upload complete
The whitelisted local prefixes (_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.
check_vpn_ip is device authentication by network trust, not cryptographic identity. There is no per-drone secret or signature — any request whose source or X-Forwarded-For / X-Real-IP starts with 10.71. is trusted as that drone. Two things a refactor must preserve or deliberately fix:
  • Header spoofing. The decorator trusts X-Forwarded-For / X-Real-IP. If the Gateway is ever reachable by a client that can set those headers (a misconfigured reverse proxy, or exposure outside the VPN/jumphost), that client can impersonate any drone. The reverse proxy in front of the Gateway must strip/overwrite these headers from untrusted sources.
  • X-Drone-IP with a SKYHUB_SITL_ prefix is trusted whenever ENABLE_SITL is true — including on non-local/remote deployments, not just in local dev. Only other local prefixes are gated behind IS_LOCAL_ENVIRONMENT.
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.
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.
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 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.
If STRIPE_WEBHOOK_SECRET is unset, signature verification is skipped on local environments (the raw payload is trusted). On non-local deployments an unset secret makes the handler reject every webhook. Never run a non-local deployment without STRIPE_WEBHOOK_SECRET set.

Socket.IO handshake auth

The telemetry Socket.IO namespace authenticates independently of flask-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.
Two socket-security caveats a refactor must not lose:
  1. The handshake never checks BLOCKLIST. A revoked (logged-out) token keeps working on Socket.IO until it naturally expires.
  2. Telemetry rooms are drone-scoped, not user-scoped. Subscribing joins f"drone_{drone_id}_{stream_type}" (socket_routes.py:123) and emission targets the same room — there is no ownership check on subscribe_telemetry. Ownership is enforced only when the rosbridge connection is first created over HTTP, not per socket message. Any authenticated user who knows (or guesses) another user’s drone_id can join that room and receive its live telemetry. (Note: CLAUDE.md documents a user_{user_id}_drone_{drone_id}_{stream_type} room name — that is wrong; the real name is drone-scoped, and the exact string is load-bearing for both subscription and emission.)
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 in main.py:188-210, selected by IS_LOCAL_ENVIRONMENT:
EnvironmentHTTP CORS originsSocket.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.aisame prod origins
Allowed methods are 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_KEY is read in settings.py:112-123. On non-local deployments an empty value raises RuntimeError at import time (before validate_critical_config even runs). Only when IS_LOCAL_ENVIRONMENT does it fall back to the insecure literal test-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) raises RuntimeError if REGION, DB_IP, or JWT_SECRET_KEY are empty, and additionally requires VPN_BUCKET when not local.
  • Other secrets to protect (all via env): STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET, STRIPE_PUBLISHABLE_KEY, the DB_* credentials, MAIL_PASSWORD, and REDIS_PASSWORD / SITL_REDIS_PASSWORD. AWS access is via the task role / boto3 default credential chain (ECR, S3, SSM, STS, EC2).
Never commit real values — this doc uses placeholders like 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

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

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.