Skip to content

Token authentication

A personal access token authenticates an HTTP client without a browser session. The client sends the complete secret in Authorization: Bearer …; the token guard looks up its record and resolves its owner. Orionis uses opaque credentials with revocation and optional expiration. Their value is not a JWT payload and does not need decoding.

The usual workflow is to sign in to the application, issue a token through an authorized operation, deliver it once and use it on protected API routes. Sessions, guards and permissions explain the related components.

The model configured in auth.identity.model must implement IAuthenticatable and IAuthorizable. For an ORM model, use the Authenticatable and Authorizable mixins exported by orionis.auth; the default User model already includes them. A token stores both the owner’s identifier and its type, derived from getAuthorizableType().

The application includes m0000000011_create_personal_access_tokens_table.py, which creates personal_access_tokens. Once your database connection is configured, apply pending migrations using either alternative:

Terminal window
orionis migrate
Terminal window
python -B reactor migrate

These commands apply all pending migrations. If you change auth.tokens.table, provide a table with the corresponding schema as well; changing configuration does not rewrite the existing migration.

This example issues a read-only token for seven days. Create app/http/controllers/access_token_controller.py:

from datetime import UTC, datetime, timedelta
from orionis.http import JSONResponse, response
from orionis.support.facades import Auth
class AccessTokenController:
async def store(self) -> JSONResponse:
await Auth.authorize("tokens.issue")
issued = await Auth.createToken(
"report-client",
abilities=("reports.read",),
expires_at=datetime.now(UTC) + timedelta(days=7),
)
return response.json(
{
"token_type": "Bearer",
"token": issued.plain_text,
"metadata": issued.toDict(),
},
status_code=201,
headers={"Cache-Control": "no-store"},
)

Register issuance in routes/web.py:

from app.http.controllers.access_token_controller import AccessTokenController
from orionis.auth.middleware import AuthenticateSessionMiddleware
from orionis.support.facades import Route
Route.post("/account/tokens", [AccessTokenController, "store"]).middleware(
AuthenticateSessionMiddleware,
)

The route requires an authenticated session and retains CSRF protection from the WEB pipeline. The identity needs tokens.issue to issue credentials and reports.read to use the report endpoint below. These are example application permissions: assign them through the permission registrar.

Auth.createToken() uses the current identity as owner. Its optional tokenable= argument selects another IAuthorizable object from code that has already authorized that operation; it does not independently decide whether the caller may manage that owner. A token-authenticated request cannot issue another token through this method, even with an explicit tokenable=.

ArgumentPurposeDefault
nameRequired non-empty label of up to 255 characters.No default.
tokenableExplicit owner implementing IAuthorizable.Current request identity.
abilitiesCollection of names restricting the credential. A string is not accepted as a collection.None, no additional restriction.
expires_atExplicit expiration, such as a UTC datetime.None, applies auth.tokens.expiration.

The collection accepts at most 256 non-empty names of up to 255 characters each; duplicates are removed. Do not use "*" as a wildcard: abilities use exact name comparisons.

What the client receives and what is stored

Section titled “What the client receives and what is stored”

NewAccessToken has two properties:

  • plain_text: the secret delivered to the client, available only in the issuance result.
  • access_token: an immutable AccessToken entity containing the identifier, owner, name, abilities and timestamps.

issued.toDict() returns {"access_token": …} metadata without plain_text. Its repr() also omits the secret. Access issued.plain_text explicitly when delivering it and avoid logging it.

The secret is generated by secrets.token_urlsafe(secret_bytes). Send the entire value: it does not use an id|secret format. The database stores only its 64-character SHA-256 digest, alongside tokenable_type, tokenable_id, abilities, expires_at, last_used_at, revoked_at and creation/update timestamps. There is no operation to recover the secret later.

Create app/http/controllers/token_report_controller.py. This example uses method injection: IAuthManager exposes the same operations as the Auth facade.

from orionis.auth.contracts.manager import IAuthManager
from orionis.http import JSONResponse, response
class TokenReportController:
async def index(self, auth: IAuthManager) -> JSONResponse:
await auth.authorize("reports.read")
return response.json({
"user_id": auth.identifier(),
"reports": [{"id": 1, "title": "Monthly summary"}],
})
async def revoke(self, auth: IAuthManager) -> JSONResponse:
revoked = await auth.revokeCurrentToken()
return response.json({"revoked": revoked})

In routes/api.py:

from app.http.controllers.token_report_controller import TokenReportController
from orionis.auth.middleware import AuthenticateTokenMiddleware
from orionis.support.facades import Route
Route.group(
prefix="/api",
middleware=AuthenticateTokenMiddleware,
routes=[
Route.get("/token-reports", [TokenReportController, "index"]),
Route.delete("/current-token", [TokenReportController, "revoke"]),
],
)

This example declares its prefix explicitly. Placing a route in routes/api.py selects the API pipeline; it does not automatically prepend /api.

Terminal window
curl --header "Accept: application/json" \
--header "Authorization: Bearer YOUR_PERSONAL_ACCESS_TOKEN" \
http://localhost:8000/api/token-reports

Replace YOUR_PERSONAL_ACCESS_TOKEN with the issued value. The guard reads request.bearerToken; an absent, empty or duplicated header supplies no usable credential. The Bearer scheme is case-insensitive. An X-API-Key header, query parameter or cookie does not activate this guard.

The API pipeline resolves identity even on public routes, but lets guests continue. AuthenticateTokenMiddleware requires a valid token before the controller runs. Unauthenticated requests receive 401; a valid token without reports.read makes authorize() raise 403. See authentication middleware.

Abilities restrict existing authorization; they do not grant permissions to the owner. For Auth.can("reports.read") to return true, the identity must have that permission and the credential must allow it.

Issued abilitiesEffect on can()
NoneRetains the owner’s permissions without an additional filter.
("reports.read",)Allows a successful check only for that permission, provided the owner already has it.
()Allows no permission.

A read-only token cannot delete reports even if its owner has reports.delete. It does not gain reports.read when its owner lacks that permission either.

Roles still belong to the identity: hasRole() may report one even for a restricted token. Require permissions or policies when checking access with restricted credentials; role middleware rejects these credentials to prevent bypassing their limit. Policies have their own ability checks, described in that guide.

List and revoke the current owner’s tokens

Section titled “List and revoke the current owner’s tokens”

The repository’s public API does not have a listing method. To display metadata on an account page, select public columns and always scope by both owner type and owner identifier. This controller uses constructor injection for the repository and method injection for the application/query builder.

Create app/http/controllers/account_token_controller.py:

from orionis.auth.contracts.token_repository import IAccessTokenRepository
from orionis.foundation.contracts.application import IApplication
from orionis.http import JSONResponse, response
from orionis.orm.contracts.query_builder import IQueryBuilder
from orionis.support.facades import Auth
class AccountTokenController:
def __init__(self, tokens: IAccessTokenRepository) -> None:
self.tokens = tokens
async def index(self, app: IApplication, db: IQueryBuilder) -> JSONResponse:
owner = Auth.user()
table = app.config("auth.tokens.table")
rows = await (
db.table(table)
.select("id", "name", "abilities", "expires_at", "last_used_at", "revoked_at")
.where("tokenable_type", owner.getAuthorizableType())
.where("tokenable_id", str(owner.getAuthorizableId()))
.orderBy("id", "desc")
.get()
)
return response.json({"tokens": list(rows)})
async def destroy(
self,
token_id: int,
app: IApplication,
db: IQueryBuilder,
) -> JSONResponse:
owner = Auth.user()
owned = await (
db.table(app.config("auth.tokens.table"))
.where("id", token_id)
.where("tokenable_type", owner.getAuthorizableType())
.where("tokenable_id", str(owner.getAuthorizableId()))
.exists()
)
if not owned:
return response.json({"message": "Token not found"}, status_code=404)
return response.json({"revoked": await self.tokens.revoke(token_id)})
async def revokeAll(self) -> JSONResponse:
count = await self.tokens.revokeAll(Auth.user())
return response.json({"revoked_count": count})

This example assumes the User model with Authorizable and the same repository/database connection used for tokens. In this direct query, abilities is its stored JSON value or None, rather than an AccessToken entity. Do not select or expose the token digest column.

Add these routes to routes/web.py:

from app.http.controllers.account_token_controller import AccountTokenController
from orionis.auth.middleware import AuthenticateSessionMiddleware
from orionis.support.facades import Route
Route.group(
prefix="/account/tokens",
middleware=AuthenticateSessionMiddleware,
routes=[
Route.get("", [AccountTokenController, "index"]),
Route.delete("/{token_id:int}", [AccountTokenController, "destroy"]),
Route.post("/revoke-all", [AccountTokenController, "revokeAll"]),
],
)

Write operations retain CSRF protection. Accepting a token_id and calling revoke() is not enough: the repository does not authorize the caller or check ownership for them.

await Auth.revokeCurrentToken() revokes the current context’s token and replaces that context with a guest context. It returns True when the call revokes a still-active token; if the request has no token credential it returns False. After revocation, the next request using that secret receives 401 on a protected route.

Terminal window
curl --request DELETE \
--header "Accept: application/json" \
--header "Authorization: Bearer YOUR_PERSONAL_ACCESS_TOKEN" \
http://localhost:8000/api/current-token

Auth.logout() belongs to session authentication and does not replace personal token revocation.

Configuration / environmentDefaultMeaning
tokens.table / AUTH_TOKEN_TABLEpersonal_access_tokensCredential table.
tokens.expiration / AUTH_TOKEN_EXPIRATIONNoneLifetime in minutes; None sets no automatic expiration.
tokens.secret_bytes / AUTH_TOKEN_SECRET_BYTES40Random bytes before encoding; accepts 32 to 128.

An explicit expires_at overrides the configured lifetime. Passing None applies that lifetime: to issue credentials without automatic expiration, configuration must use expiration=None. Dates are normalized to UTC for persistence. A token stops being accepted at its deadline even while its row remains stored.

IAccessTokenRepository exposes these asynchronous operations:

MethodResult
create(owner, name, *, abilities=None, expires_at=None)NewAccessToken containing the secret and metadata.
findByPlainText(secret)AccessToken, or None if unknown, revoked or expired.
touch(token_id)bool; updates last_used_at only while unrevoked and unexpired.
revoke(token_id)bool; marks revoked_at without deleting the row.
revokeAll(owner)Count of previously unrevoked rows marked as revoked.
purgeExpired()Count of expired or revoked rows deleted.

The guard calls findByPlainText(), checks the owner’s type and identifier, then confirms validity with touch() before publishing its identity. Controllers do not need to update last_used_at.

You can inject the repository into a maintenance service:

from orionis.auth.contracts.token_repository import IAccessTokenRepository
class TokenMaintenance:
def __init__(self, tokens: IAccessTokenRepository) -> None:
self.tokens = tokens
async def purge(self) -> int:
return await self.tokens.purgeExpired()

Physical cleanup requires your application to invoke this service from its maintenance task. The guard rejects expired or revoked tokens without waiting for cleanup.

When disabling an account or changing its credentials outside the recovery flow, explicitly decide which tokens to revoke. The token guard checks the identity and stored credential; it does not independently impose a condition on the active column. Password recovery does revoke the owner’s tokens during its transaction.