Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
48f7b47
feat: add API key authentication support
aminghadersohi Feb 14, 2026
8bb4d37
fix: apply black formatting to API key auth files
aminghadersohi Feb 14, 2026
d76373d
fix: revert to black 23.10 formatting for decorators.py
aminghadersohi Feb 14, 2026
42cee23
fix: remove unused imports in test_api_key.py
aminghadersohi Feb 14, 2026
70ce67b
fix: ensure ApiKey permissions are created when update_perms is False
aminghadersohi Feb 17, 2026
97eb580
feat: add lookup_hash for O(1) key validation, address review feedback
aminghadersohi Feb 19, 2026
2a51dcd
style: apply black formatting to models and manager
aminghadersohi Feb 19, 2026
d26206e
fix: use HMAC for lookup hash to resolve CodeQL alerts
aminghadersohi Feb 19, 2026
886933c
fix: remove unused hashlib import, suppress CodeQL false positive
aminghadersohi Feb 19, 2026
2dd738b
fix: use BLAKE2b for lookup hash to resolve CodeQL alerts
aminghadersohi Feb 19, 2026
303a9e4
fix: use scrypt for lookup hash to satisfy CodeQL
aminghadersohi Feb 20, 2026
7b9e649
fix: address PR review - 401 vs 403, public method, OpenAPI spec, docs
aminghadersohi Feb 27, 2026
168ee87
fix: use black 23.10 formatting for decorators.py to pass CI lint
aminghadersohi Feb 27, 2026
c1d2c5b
fix: import USERNAME_READONLY in test_api_key to fix lint
aminghadersohi Feb 27, 2026
1bd0860
fix: use no-permission role in 403 test instead of ReadOnly
aminghadersohi Feb 27, 2026
92d7393
fix: clean up noperms_user in tearDown to prevent test pollution
aminghadersohi Feb 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions docs/rest_api.rst
Original file line number Diff line number Diff line change
Expand Up @@ -517,6 +517,28 @@ methods::
class ExampleApi(BaseApi):
base_permissions = ['can_private']

API Key Authentication
~~~~~~~~~~~~~~~~~~~~~~

In addition to JWT tokens, FAB supports API key authentication. API keys are long-lived
Bearer tokens useful for service-to-service communication or automation scripts.

To enable API key authentication, set ``FAB_API_KEY_ENABLED = True`` in your config.

Once enabled, you can use an API key in the same way as a JWT token::

$ curl http://localhost:8080/api/v1/example/private \
-H "Authorization: Bearer sst_<YOUR_API_KEY>"

API keys are distinguished from JWT tokens by their prefix (default: ``sst_``). When the
``protect()`` decorator receives a request with an API key:

- If the key is **invalid**, the endpoint returns HTTP **401 Unauthorized**.
- If the key is valid but the user **lacks permission**, the endpoint returns HTTP **403 Forbidden**.
- If the key is valid and the user **has permission**, the request proceeds normally.

The OpenAPI spec for protected endpoints lists both ``jwt`` and ``api_key`` as valid security
schemes, so clients can choose either authentication method.

You can create an alternate JWT user loader, this can be useful if you want
to use an external Authentication provider and map the JWT identity to your
Expand Down
41 changes: 41 additions & 0 deletions docs/security.rst
Original file line number Diff line number Diff line change
Expand Up @@ -581,6 +581,47 @@ The rate can be changed by adjusting ``AUTH_RATE_LIMIT`` to, for example, ``1 pe
at the `documentation <https://flask-limiter.readthedocs.io/en/stable/>`_ of Flask-Limiter for more options and
examples.

Authentication: API Keys
------------------------

FAB supports API key authentication as an alternative to JWT tokens. API keys are long-lived
credentials that can be used for service-to-service communication or automation.

**Enabling API Key Authentication**

Set the following in your config::

FAB_API_KEY_ENABLED = True

**Creating API Keys**

API keys are managed through the ``SecurityManager``. You can create keys programmatically::

from flask import current_app

sm = current_app.appbuilder.sm
api_key = sm.create_api_key(user=user, name="my-service-key")

The returned key string should be stored securely -- it cannot be retrieved again after creation.

**Using API Keys**

Pass the API key as a Bearer token in the ``Authorization`` header::

$ curl http://localhost:8080/api/v1/example/private \
-H "Authorization: Bearer sst_<YOUR_API_KEY>"

API keys use the same permission system as regular users. The key inherits the roles and
permissions of the user it belongs to.

**Configuration Options**

The following configuration options are available:

- ``FAB_API_KEY_ENABLED`` -- Set to ``True`` to enable API key authentication (default: ``False``).
- ``FAB_API_KEY_PREFIXES`` -- List of prefixes that identify API keys vs JWT tokens
(default: ``["sst_"]``).

Role based
----------

Expand Down
4 changes: 2 additions & 2 deletions flask_appbuilder/api/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ def wraps(self: "BaseApi", *args: Any, **kwargs: Any) -> Response:


def rison(
schema: Optional[Dict[str, Any]] = None
schema: Optional[Dict[str, Any]] = None,
) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
"""
Use this decorator to parse URI *Rison* arguments to
Expand Down Expand Up @@ -701,7 +701,7 @@ def operation_helper(
# Merge docs spec and override spec
operation_spec.update(override_method_spec.get(method.lower(), {}))
if self.get_method_permission(func.__name__):
operation_spec["security"] = [{"jwt": []}]
operation_spec["security"] = [{"jwt": []}, {"api_key": []}]
operations[method.lower()] = operation_spec
else:
operations[method.lower()] = {}
Expand Down
7 changes: 7 additions & 0 deletions flask_appbuilder/security/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,13 @@ def add_apispec_components(self, api_spec):
jwt_scheme = {"type": "http", "scheme": "bearer", "bearerFormat": "JWT"}
api_spec.components.security_scheme("jwt", jwt_scheme)
api_spec.components.security_scheme("jwt_refresh", jwt_scheme)
api_key_scheme = {
"type": "http",
"scheme": "bearer",
"bearerFormat": "API Key",
"description": "API key authentication using Bearer token",
}
api_spec.components.security_scheme("api_key", api_key_scheme)

@expose("/login", methods=["POST"])
@safe
Expand Down
19 changes: 19 additions & 0 deletions flask_appbuilder/security/decorators.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,25 @@ def wraps(self, *args, **kwargs):
permission_str, class_permission_name
):
return f(self, *args, **kwargs)
# Check API key authentication (before JWT)
if current_app.config.get("FAB_API_KEY_ENABLED", False):
api_key_string = (
current_app.appbuilder.sm.extract_api_key_from_request()
)
if api_key_string is not None:
user = current_app.appbuilder.sm.validate_api_key(api_key_string)
if not user:
return self.response_401()
if current_app.appbuilder.sm.has_access(
permission_str, class_permission_name
):
return f(self, *args, **kwargs)
log.warning(
LOGMSG_ERR_SEC_ACCESS_DENIED,
permission_str,
class_permission_name,
)
return self.response_403()
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's distinguish between a valid key with no authorization from an invalid key:

  if api_key_string is not None:
      user = current_app.appbuilder.sm.validate_api_key(api_key_string)
      if not user:
          return self.response_401()
      if current_app.appbuilder.sm.has_access(
          permission_str, class_permission_name
      ):
          return f(self, *args, **kwargs)
      return self.response_403()

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done -- invalid key now returns response_401(), and a valid key without permission returns response_403(). Updated the test expectation to match.

# if no browser login then verify JWT
if not (self.allow_browser_login or allow_browser_login):
verify_jwt_in_request()
Expand Down
104 changes: 104 additions & 0 deletions flask_appbuilder/security/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -575,6 +575,8 @@ def auth_rate_limit(self) -> str:

@property
def current_user(self):
if getattr(g, "_api_key_user", False) and hasattr(g, "user"):
return g.user
if current_user.is_authenticated:
return g.user
elif current_user_jwt:
Expand Down Expand Up @@ -1869,6 +1871,11 @@ def has_access(self, permission_name: str, view_name: str) -> bool:
"""
Check if current user or public has access to view or menu
"""
# Check API key authenticated user first
if getattr(g, "_api_key_user", False) and hasattr(g, "user"):
user = g.user
if user and user.is_active:
return self._has_view_access(user, permission_name, view_name)
if current_user.is_authenticated and current_user.is_active:
return self._has_view_access(g.user, permission_name, view_name)
elif current_user_jwt and current_user_jwt.is_active:
Expand Down Expand Up @@ -2456,6 +2463,103 @@ def load_user_jwt(self, _jwt_header, jwt_data):
g.user = user
return user

"""
----------------------
API KEY AUTHENTICATION
----------------------
"""

@staticmethod
def extract_api_key_from_request() -> Optional[str]:
"""
Extract an API key from the request's Authorization header.

Checks for Bearer tokens that match configured API key prefixes
(FAB_API_KEY_PREFIXES config, default: ["sst_"]).

Returns the raw API key string if a matching prefix is found,
or None if the token is not an API key (e.g., a JWT).
"""
auth_header = request.headers.get("Authorization", "")
if not auth_header.lower().startswith("bearer "):
return None
token = auth_header[7:].strip()
if not token:
return None
prefixes = current_app.config.get("FAB_API_KEY_PREFIXES", ["sst_"])
for prefix in prefixes:
if token.startswith(prefix):
return token
return None

def validate_api_key(self, api_key_string: str) -> Optional[Any]:
"""
Validate an API key and return the associated User if valid.

Uses a fast lookup hash (configurable via FAB_API_KEY_LOOKUP_HASH_METHOD,
default: "sha256") for O(1) retrieval, then verifies against the slow
key_hash for defense in depth.

Override in subclass to provide storage-specific implementation.

:param api_key_string: The raw API key (e.g., "sst_abc123...")
:return: User object if valid, None otherwise
"""
raise NotImplementedError

def create_api_key(
self,
user: Any,
name: str,
scopes: Optional[str] = None,
expires_on: Optional[datetime.datetime] = None,
) -> Optional[Dict[str, Any]]:
"""
Create a new API key for a user.

Override in subclass to provide storage-specific implementation.

:param user: The user to create the key for
:param name: A friendly name for the key
:param scopes: Optional comma-separated scopes
:param expires_on: Optional expiration datetime
:return: Dict with key info including plaintext key (shown once)
"""
raise NotImplementedError

def revoke_api_key(self, uuid: str) -> bool:
"""
Revoke an API key by UUID.

Override in subclass to provide storage-specific implementation.

:param uuid: The UUID of the key to revoke
:return: True if revoked, False if not found
"""
raise NotImplementedError

def find_api_keys_for_user(self, user_id: int) -> List[Any]:
"""
Find all API keys for a user.

Override in subclass to provide storage-specific implementation.

:param user_id: The user's ID
:return: List of ApiKey objects
"""
raise NotImplementedError

def get_api_key_by_uuid(self, uuid: str) -> Optional[Any]:
"""
Get an API key by its UUID.

Override in subclass to provide storage-specific implementation.

:param uuid: The API key's UUID
:return: ApiKey object or None
"""
raise NotImplementedError

@staticmethod
def before_request():
g.user = current_user
1 change: 1 addition & 0 deletions flask_appbuilder/security/sqla/apis/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from flask_appbuilder.security.sqla.apis.api_key import ApiKeyApi # noqa: F401
from flask_appbuilder.security.sqla.apis.group import GroupApi # noqa: F401
from flask_appbuilder.security.sqla.apis.permission import PermissionApi # noqa: F401
from flask_appbuilder.security.sqla.apis.permission_view_menu import ( # noqa: F401
Expand Down
1 change: 1 addition & 0 deletions flask_appbuilder/security/sqla/apis/api_key/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from .api import ApiKeyApi # noqa: F401
Loading
Loading