diff --git a/gns3server/api/routes/controller/__init__.py b/gns3server/api/routes/controller/__init__.py
index f34d342c3..2891642f9 100644
--- a/gns3server/api/routes/controller/__init__.py
+++ b/gns3server/api/routes/controller/__init__.py
@@ -60,6 +60,7 @@ from . import roles
from . import acl
from . import pools
from . import privileges
+from . import api_keys
from .dependencies.authentication import get_current_active_user
@@ -192,3 +193,9 @@ router.include_router(
dependencies=[Depends(get_current_active_user)],
tags=["GNS3 Copilot"]
)
+
+router.include_router(
+ api_keys.router,
+ dependencies=[Depends(get_current_active_user)],
+ tags=["API Keys"]
+)
diff --git a/gns3server/api/routes/controller/api_keys.py b/gns3server/api/routes/controller/api_keys.py
new file mode 100644
index 000000000..4a6260761
--- /dev/null
+++ b/gns3server/api/routes/controller/api_keys.py
@@ -0,0 +1,127 @@
+#
+# Copyright (C) 2026 GNS3 Technologies Inc.
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see .
+
+"""
+API routes for API key management.
+"""
+
+import secrets
+import bcrypt
+from uuid import uuid4, UUID
+
+from fastapi import APIRouter, Depends, status
+
+from gns3server import schemas
+from gns3server.schemas.controller.tokens import TokenData
+from gns3server.db.repositories.api_keys import ApiKeysRepository
+from gns3server.db.repositories.users import UsersRepository
+from .dependencies.database import get_repository
+from .dependencies.authentication import get_current_active_user
+
+import logging
+
+log = logging.getLogger(__name__)
+
+router = APIRouter(prefix="/access/api-keys", tags=["API Keys"])
+
+API_KEY_PREFIX = "gns3_"
+API_KEY_BYTES = 32 # 256-bit key, results in 64 hex chars
+
+
+def _generate_api_key() -> tuple[str, str, str]:
+ """Generate a new API key.
+
+ Returns:
+ Tuple of (full_key, key_hash, key_prefix)
+ """
+ random_bytes = secrets.token_hex(API_KEY_BYTES)
+ raw_key = API_KEY_PREFIX + random_bytes
+ key_hash = bcrypt.hashpw(raw_key.encode(), bcrypt.gensalt()).decode()
+ key_prefix = raw_key[: len(API_KEY_PREFIX) + 8] # gns3_ + first 8 hex chars
+ return raw_key, key_hash, key_prefix
+
+
+@router.post(
+ "",
+ status_code=status.HTTP_201_CREATED,
+)
+async def create_api_key(
+ api_key_data: schemas.ApiKeyCreate,
+ current_user: schemas.User = Depends(get_current_active_user),
+ api_keys_repo: ApiKeysRepository = Depends(get_repository(ApiKeysRepository)),
+) -> dict:
+ """Create a new API key. The full key is returned only once."""
+
+ raw_key, key_hash, key_prefix = _generate_api_key()
+ db_key = await api_keys_repo.create_api_key(
+ api_key_id=uuid4(),
+ user_id=current_user.user_id,
+ name=api_key_data.name,
+ key_hash=key_hash,
+ key_prefix=key_prefix,
+ )
+ return {
+ "api_key_id": str(db_key.api_key_id),
+ "api_key": raw_key,
+ "name": db_key.name,
+ "key_prefix": db_key.key_prefix,
+ "created_at": db_key.created_at.isoformat() if db_key.created_at else None,
+ }
+
+
+@router.get("")
+async def list_api_keys(
+ current_user: schemas.User = Depends(get_current_active_user),
+ api_keys_repo: ApiKeysRepository = Depends(get_repository(ApiKeysRepository)),
+) -> list[dict]:
+ """List all API keys for the current user."""
+
+ keys = await api_keys_repo.get_api_keys_by_user(current_user.user_id)
+ return [
+ {
+ "api_key_id": str(k.api_key_id),
+ "name": k.name,
+ "key_prefix": k.key_prefix,
+ "created_at": k.created_at.isoformat() if k.created_at else None,
+ "last_used_at": k.last_used_at.isoformat() if k.last_used_at else None,
+ "revoked": k.revoked,
+ }
+ for k in keys
+ ]
+
+
+@router.delete(
+ "/{api_key_id}",
+ status_code=status.HTTP_204_NO_CONTENT,
+)
+async def revoke_api_key(
+ api_key_id: UUID,
+ current_user: schemas.User = Depends(get_current_active_user),
+ api_keys_repo: ApiKeysRepository = Depends(get_repository(ApiKeysRepository)),
+) -> None:
+ """Revoke an API key (soft delete — sets revoked=True)."""
+
+ key = await api_keys_repo.get_api_key(api_key_id)
+ if not key:
+ from fastapi import HTTPException
+ raise HTTPException(status_code=404, detail="API key not found")
+
+ # Only the key owner can revoke it
+ if key.user_id != current_user.user_id:
+ from fastapi import HTTPException
+ raise HTTPException(status_code=403, detail="Cannot revoke another user's API key")
+
+ await api_keys_repo.revoke_api_key(api_key_id)
diff --git a/gns3server/api/routes/mcp/__init__.py b/gns3server/api/routes/mcp/__init__.py
index cff1ae774..01066209c 100644
--- a/gns3server/api/routes/mcp/__init__.py
+++ b/gns3server/api/routes/mcp/__init__.py
@@ -32,6 +32,7 @@ import asyncio
import logging
import socket
import uuid
+import bcrypt
from typing import Any, Annotated
from urllib.parse import parse_qs
@@ -43,9 +44,16 @@ from pydantic import Field
from mcp.server.fastmcp import FastMCP
from mcp.server.transport_security import TransportSecuritySettings
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
from gns3server.config import Config
+from gns3server.services.authentication import AuthService
+import gns3server.db.models as models
from gns3server.services import auth_service
from gns3server.utils.request_utils import extract_client_info
+from gns3server.db.repositories.api_keys import ApiKeysRepository
+from gns3server.db.repositories.users import UsersRepository
from .projects import (
list_projects_handler, get_project_handler, create_project_handler,
delete_project_handler, open_project_handler, close_project_handler,
@@ -111,6 +119,9 @@ from .drawings import (
log = logging.getLogger(__name__)
+# Database engine reference — set during register_starlette_routes
+_db_engine = None
+
# ── Server ready state ────────────────────────────────────────────────
# Tracks whether GNS3 server has completed initialization.
@@ -176,13 +187,40 @@ _jwt_token_var: contextvars.ContextVar[str | None] = contextvars.ContextVar(
# ── Token validation ──────────────────────────────────────────────────
-async def _validate_token(token: str) -> bool:
- """Return True if token is a valid GNS3 JWT."""
+async def _resolve_token(token: str) -> str | None:
+ """Validate a token (JWT or API key) and return the effective JWT to use.
+
+ For JWT tokens, returns the token as-is.
+ For API keys, validates against the database and returns a fresh short-lived JWT.
+
+ Returns None if the token is invalid.
+ """
+ # Try JWT first
try:
auth_service.get_username_from_token(token)
- return True
+ return token
except Exception:
- return False
+ pass
+
+ # Try API key
+ if token.startswith("gns3_") and _db_engine is not None:
+ try:
+ async with AsyncSession(_db_engine, expire_on_commit=False) as db_session:
+ repo = ApiKeysRepository(db_session)
+ query = select(models.ApiKey).where(models.ApiKey.revoked == False)
+ result = await db_session.execute(query)
+ for db_key in result.scalars().all():
+ if bcrypt.checkpw(token.encode(), db_key.key_hash.encode()):
+ await repo.update_last_used(db_key.api_key_id)
+ user_repo = UsersRepository(db_session)
+ user = await user_repo.get_user(db_key.user_id)
+ if user:
+ svc = AuthService()
+ return svc.create_access_token(user.username, expires_in=5)
+ except Exception:
+ pass
+
+ return None
# ── Server URL helper ─────────────────────────────────────────────────
@@ -1305,11 +1343,16 @@ def _make_auth_wrapper(inner_app):
tokens = params.get("token", [])
if tokens:
token = tokens[0]
- if not token or not await _validate_token(token):
+ if not token:
response = Response("Missing or invalid token", status_code=401)
await response(scope, receive, send)
return
- _jwt_token_var.set(token)
+ resolved = await _resolve_token(token)
+ if not resolved:
+ response = Response("Missing or invalid token", status_code=401)
+ await response(scope, receive, send)
+ return
+ _jwt_token_var.set(resolved)
await inner_app(scope, receive, send)
return auth_wrapper
@@ -1335,6 +1378,8 @@ async def mcp_root():
def register_starlette_routes(app):
"""Mount MCP transports on the FastAPI app."""
+ global _db_engine
+ _db_engine = getattr(app.state, "_db_engine", None)
sse_app = _make_auth_wrapper(mcp.sse_app(mount_path=""))
app.mount("/v3/mcp/transport", sse_app, name="mcp-sse")
log.info("MCP SSE server mounted at /v3/mcp/transport")
diff --git a/gns3server/db/models/__init__.py b/gns3server/db/models/__init__.py
index 5e7afb07b..cd538b80d 100644
--- a/gns3server/db/models/__init__.py
+++ b/gns3server/db/models/__init__.py
@@ -24,6 +24,7 @@ from .computes import Compute
from .images import Image
from .pools import Resource, ResourcePool
from .llm_model_configs import LLMModelConfig
+from .api_keys import ApiKey
from .templates import (
Template,
CloudTemplate,
diff --git a/gns3server/db/models/api_keys.py b/gns3server/db/models/api_keys.py
new file mode 100644
index 000000000..b43ecf5f2
--- /dev/null
+++ b/gns3server/db/models/api_keys.py
@@ -0,0 +1,33 @@
+#
+# Copyright (C) 2026 GNS3 Technologies Inc.
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see .
+
+from sqlalchemy import Column, String, Boolean, DateTime, ForeignKey, func
+
+from .base import BaseTable, GUID
+
+
+class ApiKey(BaseTable):
+
+ __tablename__ = "api_keys"
+
+ api_key_id = Column(GUID, primary_key=True)
+ user_id = Column(GUID, ForeignKey("users.user_id", ondelete="CASCADE"), nullable=False, index=True)
+ name = Column(String(128), nullable=False)
+ key_hash = Column(String(128), nullable=False)
+ key_prefix = Column(String(8), nullable=False)
+ last_used_at = Column(DateTime, nullable=True)
+ created_at = Column(DateTime, server_default=func.current_timestamp(), nullable=False)
+ revoked = Column(Boolean, default=False, nullable=False)
diff --git a/gns3server/db/repositories/api_keys.py b/gns3server/db/repositories/api_keys.py
new file mode 100644
index 000000000..8b635565a
--- /dev/null
+++ b/gns3server/db/repositories/api_keys.py
@@ -0,0 +1,94 @@
+#
+# Copyright (C) 2026 GNS3 Technologies Inc.
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see .
+
+from uuid import UUID
+from typing import Optional, List
+from datetime import datetime, timezone
+from sqlalchemy import select, update, delete, func
+from sqlalchemy.ext.asyncio import AsyncSession
+from sqlalchemy.orm import selectinload
+
+from .base import BaseRepository
+import gns3server.db.models as models
+
+import logging
+
+log = logging.getLogger(__name__)
+
+
+class ApiKeysRepository(BaseRepository):
+
+ def __init__(self, db_session: AsyncSession) -> None:
+ super().__init__(db_session)
+
+ async def create_api_key(
+ self, api_key_id: UUID, user_id: UUID, name: str, key_hash: str, key_prefix: str
+ ) -> models.ApiKey:
+ db_api_key = models.ApiKey(
+ api_key_id=api_key_id,
+ user_id=user_id,
+ name=name,
+ key_hash=key_hash,
+ key_prefix=key_prefix,
+ )
+ self._db_session.add(db_api_key)
+ await self._db_session.commit()
+ await self._db_session.refresh(db_api_key)
+ return db_api_key
+
+ async def get_api_key(self, api_key_id: UUID) -> Optional[models.ApiKey]:
+ query = select(models.ApiKey).where(models.ApiKey.api_key_id == api_key_id)
+ result = await self._db_session.execute(query)
+ return result.scalars().first()
+
+ async def get_api_keys_by_user(self, user_id: UUID) -> List[models.ApiKey]:
+ query = (
+ select(models.ApiKey)
+ .where(models.ApiKey.user_id == user_id)
+ .order_by(models.ApiKey.created_at.desc())
+ )
+ result = await self._db_session.execute(query)
+ return list(result.scalars().all())
+
+ async def get_api_key_by_hash(self, key_hash: str) -> Optional[models.ApiKey]:
+ query = select(models.ApiKey).where(models.ApiKey.key_hash == key_hash)
+ result = await self._db_session.execute(query)
+ return result.scalars().first()
+
+ async def update_last_used(self, api_key_id: UUID) -> None:
+ query = (
+ update(models.ApiKey)
+ .where(models.ApiKey.api_key_id == api_key_id)
+ .values(last_used_at=func.now())
+ )
+ await self._db_session.execute(query)
+ await self._db_session.commit()
+
+ async def revoke_api_key(self, api_key_id: UUID) -> bool:
+ query = (
+ update(models.ApiKey)
+ .where(models.ApiKey.api_key_id == api_key_id)
+ .values(revoked=True)
+ )
+ result = await self._db_session.execute(query)
+ await self._db_session.commit()
+ return result.rowcount > 0
+
+ async def delete_api_key(self, api_key_id: UUID) -> bool:
+ query = delete(models.ApiKey).where(models.ApiKey.api_key_id == api_key_id)
+ result = await self._db_session.execute(query)
+ await self._db_session.commit()
+ return result.rowcount > 0
diff --git a/gns3server/db_migrations/versions/f0b0de2a9_add_api_keys_table.py b/gns3server/db_migrations/versions/f0b0de2a9_add_api_keys_table.py
new file mode 100644
index 000000000..5c438678e
--- /dev/null
+++ b/gns3server/db_migrations/versions/f0b0de2a9_add_api_keys_table.py
@@ -0,0 +1,42 @@
+"""add api_keys table
+
+Revision ID: f0b0de2a9
+Revises: a8829e6c069b
+Create Date: 2026-06-11 10:00:00.000000
+
+"""
+from alembic import op
+import sqlalchemy as sa
+import gns3server.db.models.base as models
+
+# revision identifiers, used by Alembic.
+revision = 'f0b0de2a9'
+down_revision = 'a8829e6c069b'
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+
+ op.create_table(
+ 'api_keys',
+ sa.Column('api_key_id', models.GUID(), nullable=False),
+ sa.Column('user_id', models.GUID(), nullable=False),
+ sa.Column('name', sa.String(128), nullable=False),
+ sa.Column('key_hash', sa.String(128), nullable=False),
+ sa.Column('key_prefix', sa.String(8), nullable=False),
+ sa.Column('last_used_at', sa.DateTime(), nullable=True),
+ sa.Column('created_at', sa.DateTime(), server_default=sa.func.current_timestamp(), nullable=False),
+ sa.Column('revoked', sa.Boolean(), default=False, nullable=False),
+ sa.ForeignKeyConstraint(['user_id'], ['users.user_id'], ondelete='CASCADE'),
+ sa.PrimaryKeyConstraint('api_key_id'),
+ )
+ op.create_index('ix_api_keys_user_id', 'api_keys', ['user_id'])
+ op.create_index('ix_api_keys_key_hash', 'api_keys', ['key_hash'])
+
+
+def downgrade() -> None:
+
+ op.drop_index('ix_api_keys_key_hash', table_name='api_keys')
+ op.drop_index('ix_api_keys_user_id', table_name='api_keys')
+ op.drop_table('api_keys')
diff --git a/gns3server/schemas/controller/tokens.py b/gns3server/schemas/controller/tokens.py
index 86c1a9377..e36a1d35c 100644
--- a/gns3server/schemas/controller/tokens.py
+++ b/gns3server/schemas/controller/tokens.py
@@ -28,3 +28,9 @@ class TokenData(BaseModel):
username: Optional[str] = None
token_version: int = 0
+
+
+class ApiKeyCreate(BaseModel):
+ """Schema for creating a new API key."""
+
+ name: str