---
title: "JWTManager"
section: "Extensions"
framework: WebFluid
package: webfluid
version: "1.0.0b3"
stage: "beta"
released: "August 17, 2026"
canonical: "https://docs.webfluid.dev/latest/ext/jwt.md"
html: "https://docs.webfluid.dev/latest/ext/jwt"
audience: ai-agent
format: markdown
---

# JWTManager


`EXT_JWT` signs and verifies JSON Web Tokens with a **rotating** secret. Each secret is stored in
the cache under a generated key id, which goes into the token header as `kid`, so tokens signed with
an older secret keep verifying until that secret expires. It requires **`EXT_SCHEDULING`** (to run
the rotation) and **`EXT_CACHE`** (to store the secrets).


## Enabling

```ini
[extensions]
EXT_SCHEDULING = 1
EXT_CACHE = 1
EXT_JWT = 1

[data]
REDIS_URI = redis://localhost:6379

[cache]
CACHE_TYPE = redis
```

Missing either dependency raises `FrameworkException` during `Fluid(...)`.

> **KNOWN BUG** — The signing keys live in whatever CACHE_TYPE points at, and the first rotation runs as a startup hook. With CACHE_TYPE = legacy the store is in-process, so every restart mints a new key and forgets the old ones — every token issued before that restart stops decoding. Run the JWT extension against Redis. This is not optional.

## The key lifecycle

```text
startup hook          -> _rotate_secret()
scheduled every       -> _rotate_secret()   (IntervalTrigger, JWT_ROTARY_INTERVAL days)

_rotate_secret():
    kid = uuid4().hex
    cache["jwt:<kid>"]   = secrets.token_hex(JWT_SECRET_LENGTH)   ttl 365 days
    cache["jwt:current"] = kid                                    ttl (interval + 1) days
```

Encoding always uses `jwt:current`. Decoding reads the `kid` from the token header and looks up that
specific secret — so a token signed 20 days ago still verifies while its secret is alive (a year),
even though `jwt:current` has moved on.

## The API

```python
from webfluid.core.ext import jwt

token = jwt.encode(payload, audience="default", expire=None)
token = await jwt.aencode(payload, audience="default", expire=None)

claims = jwt.decode(token, audience="default")
claims = await jwt.adecode(token, audience="default")
```

`encode` copies your payload and adds the standard claims:

| Claim        | Value                                                                       |
|--------------|-----------------------------------------------------------------------------|
| `exp`        | now + `expire` days, or `JWT_EXPIRY_DAYS`                                   |
| `iat`, `nbf` | now                                                                         |
| `iss`        | `JWT_ISSUER`                                                                |
| `aud`        | `JWT_AUDIENCES.get(audience, audience)` — an unmapped name is used verbatim |

The header carries `kid`.

```python
# fluid/services/auth.py
from webfluid.core.ext import jwt


async def issue_token(user_id: int) -> str:
    return await jwt.aencode({"sub": str(user_id)})


async def read_token(token: str) -> dict:
    return await jwt.adecode(token)
```

> **RULE** — Put the user's primary key in sub. The security battery's bearer gates resolve the principal with int(sub) — a uuid or an email raises ValueError inside the gate's fallback branch, where nothing catches it, and the request comes back 500 instead of 401.

> **RULE** — Use aencode / adecode inside async code. The sync pair reaches the cache through a blocking redis client on the event loop.

## The `kid` is untrusted input

The header arrives unverified, and its `kid` selects the cache entry the signing secret is read
from. Since `1.0.0b3` it is matched against `\A[0-9a-f]{32}\Z` — the exact shape `uuid4().hex`
produces — **before** any lookup. Anything else raises
`jwt.InvalidTokenError("Malformed key id.")`, and a `kid` that passes the shape check but names no
live entry raises `jwt.InvalidTokenError("Unknown key id.")` rather than handing `None` to the
signing library.

> **WARNING** — What that closes: kid: "current" used to resolve to the cache entry jwt:current, whose value is the key id itself — a uuid published in the header of every token the app issues. Anyone holding one valid token could read it and sign their own tokens with it, for any sub and any grant. The same trick reached jwt:revoked:<jti>.

> **RULE** — Do not hand-write kid headers, and do not reuse the jwt: prefix for your own cache keys.

## Error handling

```python
from fastapi import HTTPException


async def whoami(request):
    auth = request.headers.get("Authorization", "")
    if not auth.startswith("Bearer "):
        raise HTTPException(status_code=401, detail="MISSING_TOKEN")
    try:
        claims = await read_token(auth.removeprefix("Bearer "))
    except Exception:
        raise HTTPException(status_code=401, detail="INVALID_TOKEN")
    return {"user_id": claims["sub"]}
```

`decode` raises `jwt.InvalidTokenError` for a malformed key id, an unknown key id and a revoked
token, and the usual PyJWT exceptions (`ExpiredSignatureError`, `InvalidAudienceError`,
`InvalidIssuerError`) otherwise. Catching `jwt.InvalidTokenError` — the PyJWT base for all of them —
is enough; the bare `except Exception` above is a habit from before `1.0.0b3`, when an unknown key id
escaped as a raw `TypeError`.

## Revocation

Opt-in, and free when you do not use it. Put a `jti` claim in the payload and `decode` checks
`jwt:revoked:<jti>` in the cache before returning:

```python
from webfluid.core.ext import cache, jwt
from uuid import uuid4


async def issue_short_lived(user_id: int) -> tuple[str, str]:
    jti = uuid4().hex
    token = await jwt.aencode({"sub": str(user_id), "jti": jti}, expire=1)
    return token, jti


async def revoke(jti: str):
    # Any value works; decode only checks for existence.
    await cache.aset(f"jwt:revoked:{jti}", "1", timeout=60 * 60 * 24)
```

A token without a `jti` costs no cache lookup at all — which is the whole point of a stateless
token. Add one where you need a kill switch, leave it off everywhere else. Give the revocation entry
a TTL at least as long as the token's remaining lifetime.

## Audiences

```python
@register_config(10)
class Config:
    JWT_AUDIENCES = {
        "default": "Application",
        "api": "PublicAPI",
        "internal": "InternalServices"
    }
```

```python
token = await jwt.aencode({"sub": "1"}, audience="api")
claims = await jwt.adecode(token, audience="api")     # must match, or InvalidAudienceError
```

Audiences separate token populations minted by the same app. A token for `"api"` cannot be replayed
against a route that decodes with `"internal"`.

## Combining with the security battery

Writing header parsing by hand is worth doing once to see the shape. In practice, put a
`permissions` list into the payload and let the security gates do the work:

```python
token = await jwt.aencode({"sub": str(user.id), "permissions": ["posts:write"]})
```

```python
svc = security.user_service

async def publish(user = svc.requirement_or_grant(
    {"requirement": "has_any_role", "roles": ["editor", "admin"]},
    "posts:write"
)): ...
```

That single route now serves a browser session **and** a bearer token, and your handler receives a
real `User` either way. See [`ext/security.md`](/latest/ext/security.md).

## Config reference

| Key                   | Default                      | Notes                                                         |
|-----------------------|------------------------------|---------------------------------------------------------------|
| `JWT_ROTARY_INTERVAL` | `15`                         | Days between rotations                                        |
| `JWT_SECRET_LENGTH`   | `128`                        | Bytes passed to `secrets.token_hex`                           |
| `JWT_EXPIRY_DAYS`     | `30`                         | Default lifetime; `expire=` overrides per token               |
| `JWT_ALGORITHM`       | `"HS256"`                    | Symmetric — the secret is shared, so only this app can verify |
| `JWT_ISSUER`          | `"WebFluid"`                 | Set it to your app's name                                     |
| `JWT_AUDIENCES`       | `{"default": "Application"}` |                                                               |

> **WARNING** — Never call cache.clear() / aclear() in an app that uses JWT. flushdb drops jwt:current and every jwt:<kid>, invalidating every token in circulation.

## Next

- [`ext/security.md`](/latest/ext/security.md) — the gates that consume these tokens.
- [`ext/cache.md`](/latest/ext/cache.md) — where the keys live.
- [`surface/tooling.md`](/latest/surface/tooling.md) — the next layer.


---

## Navigation


- [Overview](/latest/.md) — what WebFluid is, how to navigate these docs, release state and known bugs
- [Getting Started](/latest/get-started.md) — install, minimal app, the layout the framework expects

**Configuration**
- [App configs](/latest/config/app-config.md) — `app_configs/<app>.ini`, switches, `*_FILE` secrets, `[dev]`
- [Config classes](/latest/config/config-class.md) — `register_config`, `DefaultConfig`, every framework key

**Extensions**
- [Introduction](/latest/ext/base.md) — `FluidExtension`, entry points, `expand_fluid`
- [Scheduling](/latest/ext/scheduling.md) — APScheduler `AsyncIOScheduler`
- [SQLAlchemy](/latest/ext/sqlalchemy.md) — `Model`, executors, binds, sessions
- [Migrate](/latest/ext/migrate.md) — Alembic wrapper, `prepare_fluid`, single/multi-db
- [Mail](/latest/ext/mailman.md) — SMTP, sync/async, batching, attachments
- [Babel](/latest/ext/babel.md) — gettext, domains, locale selection, formatters
- [Security](/latest/ext/security.md) — users, gates, CSRF, tokens, OAuth, bearer grants
- [Events](/latest/ext/events.md) — signals, events, queries, browser socket
- [Cache](/latest/ext/cache.md) — redis / legacy backends
- [JWTManager](/latest/ext/jwt.md) — encode/decode, rotation, revocation

**Frontend (surface)**
- [Introduction](/latest/surface/tooling.md) — feature switches, `fluid_base.html`, Tailwind, themes
- [Integration](/latest/surface/frontend.md) — `APP_FRONTEND`, htmx, Vite workspaces
- [Template resolution](/latest/surface/jinja.md) — loader order, namespaces, overriding

**Additives**
- [Introduction](/latest/additives/intro.md) — manifest, routers, enabling
- [Base Additives](/latest/additives/base.md) — `type: base`, `import_base`, extension semantics
- [Interaction between](/latest/additives/contract.md) — events/queries as contracts, requirements, packaging

**Framework utility**
- [Lifecycle](/latest/utils/lifecycle.md) — `mix()`, startup/shutdown hooks, graceful stop
- [Runtime](/latest/utils/runtime.md) — `FluidContext`, request hooks, themes, proxy, rate limits
- [Logging](/latest/utils/logging.md) — the log factory, levels, files, additive attribution

**CLI**
- [Introduction](/latest/cli/wf.md) — command tree, extension CLIs
- [Create](/latest/cli/create.md) — `wf create project/app/additive`
- [Run](/latest/cli/run.md) — `wf run`, flags, debug and interactive mode
- [Ocean](/latest/cli/ocean.md) — search, install, publish

**Reference**
- [Overview](/latest/ref.md) — package layout, stability contract
- [Core](/latest/ref/core.md) — `Fluid`, config, context, `core.ext`, constants
- [Extensions](/latest/ref/extensions.md) — the battery API surface
- [Surface](/latest/ref/surface.md) — `Frontend`, Node and Tailwind tooling
- [Additives](/latest/ref/additives.md) — `Additive`, `Router`, `Manifest`, registry helpers
- [Utils](/latest/ref/utils.md) — helpers, logging, exceptions