Skip to content
Back to skills

Hex Restapi Auth

ASecurity

Use when an HTTP entrypoint must authenticate its caller or gate a route on a role. Owns the caller identity, the token-verifier port and adapter, the `get_current_user` / `require_role` route dependencies, the role-rank gate, the RFC-7235 challenge, and the 401/403 codes a route advertises because of them — a route's other error codes are `hex-restapi-endpoint`'s. - Adding auth to a REST service, choosing between an authenticated and a role-gated route, wiring a token verifier, or deciding w...

  • 2 stars
  • 0 votes
  • 0 copies
  • 1 view
  • Added October 14, 2026
developmentpythonrustgoshellfastapiapisecurity

Works with

  • cli
  • api

Security analysis

A100/100

Pro scans all 2 files and shows the line behind each finding

Scanned October 14, 2026

npx -y skills add wntic/pyhouse --skill hex-restapi-auth --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Hex Restapi Auth?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Hex Restapi Auth
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/wntic-hex-restapi-auth/badge)](https://www.skillsdirectory.com/skills/wntic-hex-restapi-auth)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: hex-restapi-auth
description: Use when an HTTP entrypoint must authenticate its caller or gate a route on a role. Owns the caller identity, the token-verifier port and adapter, the `get_current_user` / `require_role` route dependencies, the role-rank gate, the RFC-7235 challenge, and the 401/403 codes a route advertises because of them — a route's other error codes are `hex-restapi-endpoint`'s. - Adding auth to a REST service, choosing between an authenticated and a role-gated route, wiring a token verifier, or deciding what an authenticated route advertises in OpenAPI.
---

# Hex REST — Auth

Authentication and transport-level authorization for a hexagonal REST service. Everything here is
**optional**: a service whose routes need no caller identity — a public API, an internal worker-facing
one, or one behind a gateway that authenticates for it and passes nothing inward — declares no auth and
never loads this skill; one that reads the caller from a gateway's header takes the gateway binding
under *Other bindings*. The REST core (`hex-restapi-app`, `hex-restapi-endpoint`,
`hex-restapi-schema`) is complete without it.

**Auth is conditional, not presumed.** An app has auth when some endpoint is non-anonymous, or a
token-verifier capability is wired — there is no separate flag for it. "Adding auth" means adding the
artifacts below; an app whose every endpoint is anonymous has **no auth layer at all**, which is the
absence of the feature rather than "skipping auth".

The principle this binds: **the entrypoint authenticates the caller, resolves a caller identity, and
passes it inward as ordinary data on a command or query DTO.** No layer below the entrypoint knows how
authentication happened. Authorization that is a *business* rule is a domain concern; authorization that
is a *transport* rule — a single role-rank check — belongs here.

## When to use vs. neighbours

- The app shell, middleware, the central error translator and `schemas/errors.py` → `hex-restapi-app`.
- A route's signature and body → `hex-restapi-endpoint`.
- Which error codes a route advertises for non-auth reasons → `hex-restapi-endpoint`, in its sibling
  `CONTRACTS.md`; registering a status a middleware introduces → `hex-restapi-app`, *Registering a
  middleware's status*.
- `UnauthorizedError` / `ForbiddenError` themselves, and boundary translation → `exception-catalog`.
- Enum and value-object form in general → `hex-domain-model`.
- How to write the capability protocol the verifier satisfies → `hex-domain-ports`.
- Adapter form in general — constructor injection, secrets, no logging, no business logic →
  `hex-capability-adapter`; this skill carries only the verifier instance of it.
- What a settings class declares → `python-settings`; binding lifetimes and composition-root declaration order → `hex-wiring`.
- Authorization finer than a single role-rank check → `hex-application`; the handler raises
  `ForbiddenError`.
- The fixtures that mint tokens, the `authenticated_client`, and the unauthenticated-probe invariant →
  `hex-test-restapi-auth`.
- A login or token-refresh request/response model, or any other per-resource wire schema → `hex-restapi-schema`; this skill owns the dependencies, not the bodies.

## The caller identity

`CurrentUser` is what the entrypoint resolves and what every auth-derived DTO field is stamped from.
It is a domain value object under a cross-cutting subdomain package (`domain/auth/`, per
`hex-domain-ports`' placement rule for cross-cutting capabilities).

### `domain/auth/current_user.py`

```python
from dataclasses import dataclass

__all__ = ["CurrentUser"]


@dataclass(frozen=True, slots=True)
class CurrentUser:
    id: str
```

`id` is the credential's subject exactly as the issuer states it. It is converted to the app's own id
type only where the issuer guarantees that form; otherwise a subject the app did not mint would fail
to parse and lock a valid caller out. A rank app adds a `role` (*Rank apps only*, below); a tenant is
added the same way, and which DTO fields are stamped from the identity, and how a handler scopes by
them, are `hex-application`'s.

## The token-verifier port

### `domain/auth/i_can_verify_token.py`

```python
from typing import Protocol

from .current_user import CurrentUser

__all__ = ["ICanVerifyToken"]


class ICanVerifyToken(Protocol):
    def verify(self, token: str) -> CurrentUser: ...
```

Signature verification is pure CPU, so the method is **sync**, not async — the capability-shape rule in
`hex-domain-ports`. **The port names no claim.** Its contract is credential in, identity out; which
keys, headers or certificate fields a scheme yields, and how they map onto the identity's fields, is the
adapter's alone (rule 12).

## The verifier adapter

### Template — PyJWT

Placement (`infrastructure/jwt/`, the external technology), constructor injection, secret handling, the
no-logging rule and not importing the port it satisfies (`ICanVerifyToken`) are
`hex-capability-adapter`'s; this is the sync pure-CPU adapter that skill describes in prose, bound to
PyJWT. Translation of the library's parse/verify errors follows `exception-catalog`.

```python
import jwt

from myapp.domain.auth import CurrentUser
from myapp.domain.exceptions import UnauthorizedError

from .jwt_settings import JwtSettings

__all__ = ["PyJwtTokenVerifier"]


class PyJwtTokenVerifier:
    def __init__(self, settings: JwtSettings) -> None:
        self._public_key = settings.public_key.get_secret_value()
        self._algorithm = settings.algorithm
        self._issuer = settings.issuer
        self._audience = settings.audience

    def verify(self, token: str) -> CurrentUser:
        try:
            claims = jwt.decode(
                token,
                self._public_key,
                algorithms=[self._algorithm],
                issuer=self._issuer,
                audience=self._audience,
                options={"require": ["exp", "sub"]},
            )
            return CurrentUser(id=claims["sub"])
        except jwt.ExpiredSignatureError as exc:
            raise UnauthorizedError("token expired", {"reason": "expired"}) from exc
        except jwt.InvalidTokenError as exc:
            raise UnauthorizedError("invalid token", {"reason": "invalid"}) from exc
```

`algorithms=[...]` is a **list of one**, read from settings — never the token's own `alg` header. A
verifier that trusts the header accepts `none` and accepts a symmetric algorithm signed with the public
key it published; the settings-side allowlist validator below is what keeps that list honest.

**The identity is built inside the translated scope.** A token can carry a valid signature and still
not describe a caller — a claim missing, a subject of the wrong type. Each of those is an unverifiable
credential and answers 401 like a bad signature, never a 500. Here `require` turns an absent claim into
the library's own `InvalidTokenError`, and PyJWT (2.10 and later) rejects a non-string `sub` the same
way. The library is declared with that floor, `pyjwt>=2.10`, wherever this verifier is
(`python-toolchain` rule 9). **A token that states no expiry is refused rather than trusted for ever**:
the library checks `exp` only when the claim is present, so the verifier requires it.

**`reason` is one of a fixed set the app owns** — `expired`, `invalid`, `missing_credentials`, and
`invalid_claims` in a rank app — never the library's exception class name: `context` reaches the
response body verbatim, where a client keys on it, and a library upgrade renames its classes
(`exception-catalog` rule 11).

### `infrastructure/jwt/jwt_settings.py`

```python
from pydantic import SecretStr, field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict

__all__ = ["JwtSettings"]

_ALLOWED_ALGORITHMS = frozenset({"RS256", "RS384", "RS512", "ES256", "EdDSA"})


class JwtSettings(BaseSettings):
    model_config = SettingsConfigDict(
        env_prefix="MYAPP_JWT_",
        env_file=".env",  # only where the project keeps a dotenv file for development
        extra="ignore",
        hide_input_in_errors=True,
    )

    algorithm: str = "RS256"
    public_key: SecretStr
    issuer: str
    audience: str

    @field_validator("algorithm")
    @classmethod
    def _reject_unlisted_algorithm(cls, value: str) -> str:
        if value not in _ALLOWED_ALGORITHMS:
            raise ValueError(f"JWT algorithm {value!r} is not in the allowlist {sorted(_ALLOWED_ALGORITHMS)}")
        return value
```

The allowlist is `python-settings` rule 12's rejection validator: an `alg` of `none`, or `HS256` against
a published public key, verifies happily and forges every identity in the system, so the settings refuse
it when they are built, before any token is verified. A service that is its own issuer may allow one HMAC algorithm with a secret
key instead, never beside a published public key. Where the deployment can pass the key only on one
line, restoring its newlines is `python-settings` rule 12's normalization. The allowlist, the
`Authorization` header and the RFC-7235 challenge are this scheme's own — an opaque token, a session
cookie and a gateway header have none of them — which is why they sit with this binding; the header is
read in one place (rule 3) and the challenge carries the scheme alone (rule 8).

## The route dependencies

### `restapi/dependencies.py`

`dependencies.py` is FastAPI's home for shared route dependencies; the auth dependency is its first
occupant, so an auth-less app has no such file (`hex-restapi-app`).

```python
from dishka.integrations.fastapi import FromDishka, inject
from fastapi import Depends
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer

from myapp.domain.auth import CurrentUser, ICanVerifyToken
from myapp.domain.exceptions import UnauthorizedError

__all__ = ["get_current_user"]

_bearer_scheme = HTTPBearer(auto_error=False)


@inject
async def get_current_user(
    verifier: FromDishka[ICanVerifyToken],
    credentials: HTTPAuthorizationCredentials | None = Depends(_bearer_scheme),
) -> CurrentUser:
    if credentials is None:
        raise UnauthorizedError("Missing bearer token", {"reason": "missing_credentials"})
    return verifier.verify(credentials.credentials)
```

The bearer scheme is declared **once** at module level. `get_current_user` receives the verifier by
its port type — never instantiate verifiers in routes or dependencies. **The `@inject` decorator is
required here even though the routers carry `route_class=DishkaRoute`**: that route class injects route
functions, not the `Depends` functions behind them (`hex-restapi-endpoint`). The verifier arrives typed,
so nothing is cast.

### Rank apps only — the role gate

Where some route gates on rank, four things are added; an app whose routes only authenticate has none
of them. `CurrentUser` gains `role: Role`. The verifier requires the claim (`"require": ["exp",
"sub", "role"]`), builds `CurrentUser(id=claims["sub"], role=Role(claims["role"]))`, and gains one arm —
`except ValueError` → `UnauthorizedError("invalid token claims", {"reason": "invalid_claims"})` —
because a role the app does not declare is an unverifiable credential, never a 500.
`domain/auth/role.py` holds the ladder:

```python
from enum import StrEnum

__all__ = ["Role"]

_RANK = {"LOWER": 0, "HIGHER": 1}


class Role(StrEnum):
    LOWER = "LOWER"
    HIGHER = "HIGHER"

    def satisfies(self, required: "Role") -> bool:
        return _RANK[self.value] >= _RANK[required.value]
```

The self-reference is quoted because the name is not bound until the class statement finishes and the
catalogue bans `from __future__ import annotations` (`python-style`). `LOWER` and `HIGHER` are
**placeholder ranks**, the way `Foo` is the placeholder aggregate: substitute the app's own members,
however many it has; a route template names the slot — `Role.<MIN_RANK>` — never a member. Its unit
test covers `satisfies` at, above and below the bar (`hex-test-domain`).

`restapi/dependencies.py` gains the gate below `get_current_user`, and `require_role` joins its
`__all__`. The gate is a callable class, not a closure, so the role it gates on is a typed attribute:

```python
from fastapi import Depends

from myapp.domain.auth import CurrentUser, Role
from myapp.domain.exceptions import ForbiddenError


class _RoleDependency:
    def __init__(self, required: Role) -> None:
        self.required_role = required

    def __call__(self, user: CurrentUser = Depends(get_current_user)) -> CurrentUser:
        if not user.role.satisfies(self.required_role):
            raise ForbiddenError(
                "Insufficient role",
                {"required": self.required_role.value, "actual": user.role.value},
            )
        return user


def require_role(required: Role) -> _RoleDependency:
    return _RoleDependency(required)
```

## The error-handler auth branch

`hex-restapi-app`'s primary `error_handler.py` is the auth-less one. An app that declares auth uses the
**authenticated** variant instead: the `UnauthorizedError` import is added and the translator grows its
one `isinstance` branch, attaching the RFC-7235 challenge. Nothing else in the file changes, and
`hex-restapi-app` rule 3 caps it at **at most one** branch.

```python
from myapp.domain.exceptions import MyappError, NotFoundError, UnauthorizedError, ValidationError
```

```python
        headers: dict[str, str] = {}
        if isinstance(exc, UnauthorizedError):
            headers["WWW-Authenticate"] = "Bearer"
        return JSONResponse(status_code=status, content=body, headers=headers or None)
```

`restapi/schemas/errors.py` maps `UnauthorizedError` to `401` in `STATUS_BY_ERROR`, and `ForbiddenError`
to `403` once something raises it (`hex-restapi-app`).

The challenge carries the `Bearer` scheme alone, which is the load-bearing part (RFC 7235) and what
`hex-test-restapi-auth` asserts. A realm is optional and app-specific: an app that wants one reads it
from its settings — never a literal frozen into the template (rule 8).

`401` says *who are you* — the credential was missing, malformed, expired or unverifiable, so the client
may retry with a better one, and RFC 7235 obliges the response to say how. `403` says *I know who you
are and you may not* — retrying with the same credential is pointless, and there is no challenge to
issue. Those are two different answers, not two spellings of one; `exception-catalog` owns both classes.

## The route's auth dependency and codes

**Read `ROUTES.md` before writing or changing any route in an app that declares auth** — only this
file is loaded automatically. It is the half consulted every time a route is written: the decision
table for which dependency an operation takes, the `_`-vs-`user` binding rule and the stamp-from-the-identity rule,
how an authenticated route is derived from an auth-free one, and the coordinated
advertisement rule joining the chosen dependency to the codes the route declares.

## The composition-root wiring

The verifier and its settings are two bindings in the composition root — an add-on a project that
declares auth merges into `hex-wiring`'s base, each line into the provider class of the same name.
Placement follows `hex-wiring`'s declaration order: settings first, then long-lived infrastructure — a verifier is process-lifetime
(stateless, parses its key once).

```python
from dishka import Provider, Scope, provide

from myapp.domain.auth import ICanVerifyToken
from myapp.infrastructure.jwt import JwtSettings, PyJwtTokenVerifier


class SettingsProvider(Provider):
    scope = Scope.APP

    @provide
    def jwt_settings(self) -> JwtSettings:
        return JwtSettings()


class InfrastructureProvider(Provider):
    scope = Scope.APP

    jwt_verifier = provide(PyJwtTokenVerifier, provides=ICanVerifyToken)
```

`ICanVerifyToken` — the port, not the adapter class — is what `get_current_user` asks for and what
`hex-test-restapi-auth` substitutes, so the adapter can be swapped without touching either. Lifetimes,
ordering and the settings lifecycle follow `hex-wiring`.

## Other bindings

- **JWKS endpoint.** The issuer publishes its key set rather than one key. Fetching and caching it by
  `kid` is a key-set client the adapter is built on, injected like any client, since an adapter does
  not cache (`hex-capability-adapter`); the adapter selects the verifying key by the token's `kid`,
  never by its `alg`. The port, the identity, the gate and the codes are unchanged.
- **Opaque token + introspection endpoint.** The port, `CurrentUser`, `require_role`, the dependency
  pair and the whole advertisement half are unchanged. What changes: the adapter does IO, so the
  capability method becomes `async` (`hex-domain-ports`' async capability shape) and the provider is
  built on an HTTP client rather than a key; the adapter form is `hex-capability-adapter`'s async
  HTTP-gateway one. A network call per request also makes a cache a real question — which the adapter
  may not answer itself — `hex-capability-adapter`'s adapters-are-thin rule (no retries, no caching); it
  is a separate wrapper.
- **Session cookie.** `HTTPBearer` is replaced by reading a signed cookie, and the RFC-7235 branch goes
  away — a cookie scheme has no `WWW-Authenticate` challenge, so a 401 carries no header. Everything
  else — the port, the identity, the role gate, the two dependencies, the code sets — is unchanged.
- **Gateway- or mTLS-authenticated.** The gateway has already authenticated, so there is no verifier and
  no port; `get_current_user` builds `CurrentUser` from a trusted header or the peer certificate. The
  gate, the codes and the 401/403 split stand. The load-bearing extra rule: the app must be unreachable
  except through the gateway, or the trusted header is a forgery primitive.
- **`dependency-injector` as the wiring mechanism.** The port, the adapter, the dependency pair and the
  codes are unchanged. What changes is two lines of `dependencies.py`: `get_current_user` takes
  `request: Request` instead of the injected parameter, drops `@inject`, and reaches the verifier by the
  binding's **attribute name** off the app state — which resolves untyped, so the result has to be
  narrowed with a `cast` to keep the `-> CurrentUser` contract. `hex-wiring`'s mapping table has the
  rest.

## Rules

1. **The entrypoint authenticates; nothing below it knows how.** The caller identity crosses inward as
   ordinary data on a command or query DTO — never a request object, a header, a token string or a
   framework dependency. `hex-application` owns the DTO field.
2. **One name for credential rejection.** Every missing, malformed, expired or unverifiable credential
   raises the catalogue's single unauthorized class, and the challenge branch keys on that one class; a
   second parallel class for the same case silently skips the challenge (`exception-catalog`).
3. **Identity is resolved once, at one place.** A route never parses the authorization header, decodes a
   token, or constructs a verifier. The verifier reaches the dependency from the composition root, by
   its port type, like anything else.
4. **The gate is a declared dependency, not code in the route body.** No hand-rolled rank comparison
   after the identity is bound. A rule more nuanced than a single role rank is a handler concern; the
   handler raises the forbidden class.
5. **The required rank is visible at the call site.** `require_role(Role.X)` is called inline at each
   route; do not memoize it at module level (`_gate = require_role(Role.HIGHER)`) — the role is the most
   important detail in a route review.
6. **Two dependencies, and they are exhaustive**: authenticate-only, and authenticate-plus-rank. Do not
   combine the authenticate-only form with a role check — use the rank form.
7. **The advertised codes match the chosen dependency.** Authenticate-only advertises the
   unauthenticated code; rank-gated advertises the unauthenticated **and** the forbidden code; no
   dependency advertises neither. A code no route can produce is a lie in the published document
   (`hex-restapi-endpoint`).
8. **A challenge, where the scheme defines one, carries the scheme and nothing else.** The realm is
   app-specific: drive it from settings or omit it, and never freeze a literal realm in a template or a
   test. The challenge belongs to the unauthenticated response alone — a forbidden response has no
   challenge to issue, and a scheme that defines no challenge sends none at all.
9. **In an app that has auth, authentication is the default for a non-public route.** A "trusted
    internal" route that skips auth is forbidden; internal-only access is enforced at the network or
    gateway layer. This does **not** manufacture auth on an app that has none.
10. **A rank ladder is the app's own** — a rank-ordered enum with a rank-satisfaction method, whose
    members and their number belong to the app; a route template names the slot, never a member.
11. **Auth-derived values come from the resolved identity, never from the request.** Which DTO fields
    they are, and how a handler scopes by them, are `hex-application`'s.
12. **The identity type is a domain type.** The verifier returns it; the credential's own wire shape —
    a claims mapping, an introspection payload, a header set — never leaves the adapter, and no layer
    above it sees one.

## Inlined typing / import rules

- **No cast at the injection boundary.** The verifier arrives as `FromDishka[ICanVerifyToken]`, which
  is a real annotation the checker follows, so `get_current_user` honours its `-> CurrentUser` contract
  with no `cast` and no `# type: ignore`. A cast here means something is being reached through an
  untyped attribute — fix the injection instead.
- `Depends` from `fastapi`; `HTTPAuthorizationCredentials`, `HTTPBearer` from
  `fastapi.security`.
- Domain imports absolute (`from myapp.domain.auth import CurrentUser`). The verifier adapter
  **never** imports the protocol it satisfies (`hex-architecture` rule 7).
- Full annotations on every dependency, the callable class's `__call__`, and the verifier's methods. No
  `from __future__ import annotations` (`python-style`).

## Package wiring

`domain/auth/` is a subdomain package — its `__init__.py` re-exports `CurrentUser` and
`ICanVerifyToken` (and `Role` in a rank app), which every `from myapp.domain.auth import ...` above resolves against — and
`infrastructure/jwt/` an infrastructure one; both follow `python-packaging`, with the layer placement
in `hex-architecture`. `restapi/dependencies.py` sits in the entrypoint package `hex-restapi-app` creates, under that package's entrypoint carve-out.

## Hard stops

- The app declares no auth (every endpoint anonymous, no token-verifier capability) → stop, produce none
  of this; there is no `dependencies.py`, no `domain/auth/`, no `UnauthorizedError`, and every route is
  auth-free by construction.
- `domain/exceptions.py` has no `UnauthorizedError` → stop, use `exception-catalog` first; `ForbiddenError`
  is added with the first thing that raises it — the role gate, or a handler's finer check.
- The translator is asked to branch on more than the unauthorized class → stop, use `hex-restapi-app`
  (rule 3).
- The verifier is asked to log, retry or cache → stop, use `hex-capability-adapter`; an adapter is thin.
- Asked for authorization finer than a single role rank — per-row ownership, a policy matrix, a third
  kind of auth dependency → stop, use `hex-application`; the handler raises `ForbiddenError`.

Files in this skill

  • ROUTES.md4.5 KB
  • SKILL.md23.2 KB

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.