Personal access tokens deprecated
A personal access token is a bearer credential a user mints for a client that cannot run an OAuth flow: a script, an integration with a fixed Authorization header, or a language-model client talking to an MCP endpoint. The token is bound to the user, limited to the scopes chosen when it was minted, expires at a chosen time and can be revoked at any time. It is accepted wherever the middleware validates bearer tokens through its OAuth provider: on every HTTP API module with restricted actions, and on any other endpoint that asks the provider. Guest users cannot mint tokens.
Minting, listing and revoking
Tokens are managed through the HTTP API module accesstoken, authenticated with a full session, never with a token:
GET <dispatcher prefix>accesstoken?action=all&session=…
GET <dispatcher prefix>accesstoken?action=new&session=…&label=laptop&scopes=read_mail,read_contacts&expires=<milliseconds since the epoch>
GET <dispatcher prefix>accesstoken?action=delete&session=…&id=<token id>
The dispatcher prefix is com.openexchange.dispatcher.prefix, /appsuite/api/ behind the usual proxy.
The same actions are available REST-like on the collection accesstokens, with the values of new as a JSON body and scopes as an array or comma-separated:
GET <dispatcher prefix>accesstokens?session=…
POST <dispatcher prefix>accesstokens?session=… {"label":"laptop","scopes":["read_mail","read_contacts"],"expires":<milliseconds since the epoch>}
DELETE <dispatcher prefix>accesstokens/<token id>?session=…
new returns the token's metadata and, once, its secret of the form oxa_<context>_<64 hex characters>; only the SHA-256 hash of the secret is stored, so a database copy reveals nothing usable. all lists the user's tokens without secrets, together with the scopes the user may grant. delete revokes a token; a session made from it is refused from then on. The requests and responses are described in the HTTP API documentation, module accesstoken.
Two limits apply, both config-cascade aware and reloadable: com.openexchange.accesstoken.maxLifetimeDays (default 365) caps how far in the future expires may lie, and com.openexchange.accesstoken.maxTokensPerUser (default 50) caps how many tokens a user holds at once; 0 removes either limit. A password change the user makes through App Suite revokes every token of the user, since a token minted with a password the user gave up must not outlive it; a password an administrator sets through provisioning does not, nor does a reset in a directory or at an identity provider. Once the primary mail server refuses the password a token keeps for mail access with AUTHENTICATIONFAILED (RFC 5530) and keeps refusing it for at least five minutes, that token and every older one of the user are revoked; a pause of fifteen minutes without a refusal starts afresh. This keeps a short outage of the mail server's user directory, which some servers answer the same way, from revoking tokens, and two such outages further apart do not add up. A locked account is refused the same way as a changed password: where a live session of the user logged in with the refused password, nothing is revoked, and where no live session logged in with a password at all, the refusal has to last com.openexchange.accesstoken.revokeOnRefusedPasswordWithoutSessionDelay (default 30 minutes) first, so that a lockout provoked with failed logins does not revoke the user's tokens. Where the user's sessions cannot be looked up, nothing is revoked. Tokens go by creation order: one minted in a session that started before the password changed carries the old password and takes older tokens with it. That needs login attempts with the old password and a mail server that sends the response code; under master authentication it never happens. com.openexchange.accesstoken.revokeOnRefusedPassword=false switches it off. Revoking tokens is therefore part of handling a compromised account. Expired tokens stay listed for a week and are then dropped by a clean-up job.
A user who may mint tokens has the capability access_tokens: the user is neither a guest nor anonymous, and the OAuth provider is enabled for the user. These are the checks new applies, so a client that shows a token page only with this capability never offers what the module would refuse. The capability does not depend on any consumer of the tokens; whether the MCP endpoint is available is the separate capability mcp.
Scopes
The scopes a user may grant are the ones the installed modules register with the OAuth provider, the same scopes an OAuth client is granted through the provider's consent page: read_contacts and write_contacts from the contacts module, read_calendar and write_calendar from the calendar, read_mail and write_mail from mail (mail filters included), read_tasks, read_reminders, read_mailaccounts, their write_ counterparts, write_userconfig, read_files from Drive (reading only; there is no write_files), and caldav and carddav for the DAV interfaces. A module that registers no scope cannot be reached with a token. A scope is offered only where the user's capabilities allow the module; a user without the calendar is not offered read_calendar. all returns each scope with a description in the user's language, for a settings page to show.
A scope opens a module wherever bearer tokens are validated, not one endpoint. A token carrying read_mail reads mail through the HTTP API as well as through any other interface that asks the provider, and read_files reads Drive through the HTTP API, where the bounds a single endpoint applies do not hold. Grant what the client should be able to reach, not what one of its features needs.
A token can hold any combination of the offered scopes. A request with a token whose scopes do not cover the action is answered with 403 and a JSON body with error insufficient_scope and, as scope, the scope the action needs.
Using a token
A client sends the secret as bearer token and leaves the session parameter out:
GET <dispatcher prefix>contacts?action=all&folder=…&columns=1,500
Authorization: Bearer oxa_1_…
The middleware creates a transient session for the token on first use and reuses it for later requests on the same node; the session's client name is accesstoken, which is what the last-login records show, and each node holding such a session counts it against com.openexchange.sessiond.maxSessionPerUser. An unknown, malformed or revoked token is 401 with WWW-Authenticate: Bearer error="invalid_token", an expired one 401 with error="invalid_token" and a description naming the expiry. When the token cannot be checked at all, because the database is unavailable, the answer is 503 with temporarily_unavailable, and the client keeps its token.
Signing in without a browser
A native app or a command line tool that cannot open App Suite first can get a token with user name and password. The action is part of the login servlet, is off by default and is enabled per user or context with com.openexchange.accesstoken.signin.enabled=true; the OAuth provider has to be enabled as for minting from a session.
POST <dispatcher prefix>login?action=accessToken&client=my-app
Content-Type: application/json
{"login": "anna@example.com", "password": "…", "label": "Anna's phone", "scopes": ["read_mail", "read_contacts"]}
The request runs through the regular login with a transient session: authentication plug-ins, login listeners and the client check apply as for a web login; failed attempts are limited as described below. The session provides the password the vault keeps for mail access and is removed before the response is sent; no cookie is set. The answer is the same as for accesstoken?action=new. App passwords and guest credentials are refused as invalid credentials, so they cannot be turned into a full token.
Omitted values fall back to configuration: label to the client identifier and the date, scopes to those of com.openexchange.accesstoken.signin.defaultScopes the user can grant, expires to com.openexchange.accesstoken.signin.defaultLifetimeDays (default 90, capped by maxLifetimeDays). com.openexchange.accesstoken.signin.allowedClients restricts which client values may sign in.
Second factor
A user with a second factor gets no token from the first call but the error ACCESSTOKEN-0012 with a challenge as data: a challengeId, its expiry and the factors to choose from. Only factors answered with a typed code are offered: authenticator apps (TOTP), backup strings and SMS; a user with WebAuthn or U2F devices only is refused with ACCESSTOKEN-0015. A user whom the enforcement of a second factor requires to set one up first is refused with ACCESSTOKEN-0013.
POST <dispatcher prefix>login?action=accessTokenSecondFactor&client=my-app
Content-Type: application/json
{"challengeId": "…", "provider": "TOTP", "deviceId": "…", "secret_code": "123456"}
The code is checked as in the web client: a wrong one counts towards the multifactor lock-out (com.openexchange.multifactor.maxBadAttempts, lockoutTime) and is answered with ACCESSTOKEN-0016, a locked-out user with ACCESSTOKEN-0017; the challenge stays valid for another attempt. Without secret_code the factor is only started, which sends the code of an SMS factor. With the right code the user is signed in again, the token is minted and the challenge is gone; a second confirmation gets ACCESSTOKEN-0014, as does one after com.openexchange.accesstoken.signin.challengeLifetime (default five minutes).
A second factor that cannot be checked at the moment, for example because its storage is unavailable, is answered with ACCESSTOKEN-0018, which is worth another try later. A requested expiry has to lie beyond the challenge's. A challenge is claimed while it is being confirmed, so a confirmation sent while another of the same challenge is still running gets ACCESSTOKEN-0014; clients wait for the answer before they try again.
The client does not send the password again. The challenge keeps it in a cluster map, sealed with the challenge identifier, and is stored under the identifier's hash: what the cluster map holds is readable only while the client presents its challenge.
The logic is the OSGi service com.openexchange.accesstoken.signin.AccessTokenSignInService, so other end-points, such as a mobile API, offer the same sign-in with the same checks, limits and challenges.
Limits
The sign-in answers with a long-lived credential without a session, so attempts are limited cluster-wide, whichever end-point uses the sign-in. Each attempt is counted against its login name before the password is checked, so parallel requests cannot slip past the limits: com.openexchange.accesstoken.signin.rateLimit.perLogin (default 5) per login name and client address, com.openexchange.accesstoken.signin.rateLimit.perLoginTotal (default 50) per login name from all addresses, and com.openexchange.accesstoken.signin.rateLimit.perIp (default 20) failures per client address within com.openexchange.accesstoken.signin.rateLimit.timeWindow (default 15 minutes, between 10 seconds and 12 hours). Beyond them a sign-in is refused with LGI-0028 without checking the password. Where com.openexchange.ajax.login.rateLimitByLogin.enabled is set, that limit of the regular login applies as well: per login name from any address, on each node. A sign-in with the full password clears the count of its login name; an app password does not, nor does a sign-in that still needs the second factor, until it is confirmed: each challenge keeps the sealed password for minutes, so issuing them is bounded like any attempt. An address counts failed attempts only, since many users may share it behind a NAT, and one that has run out is refused before a login name is charged. Failures are counted once known, so each node checks at most 4 sign-ins of one address at the same time; further ones wait for their turn, up to 10 seconds, and are then checked against the address's failures again, or refused with LGI-0028 after that wait; the limit per login name holds regardless. The address is the transport address, never the clientIP parameter; behind proxies it is right only if com.openexchange.server.knownProxies lists them. IPv6 addresses count per /64 network. The per-login limit also bounds the second-factor codes a user may try in the window, since the multifactor lock-out counts a wrong code only after checking it, and the codes an SMS factor sends; beyond it a confirmation gets ACCESSTOKEN-0017. That bound cannot be disabled: where the limits are, it takes their defaults. A second factor passed along with the first call is ignored; only a confirmation checks it. Changing a limit starts its counts afresh. If the limits cannot be evaluated, sign-ins are refused with ACCESSTOKEN-0019, which is worth another try later.
Anyone who knows a login name can use up its attempts and keep its owner from signing in for a token until the window has passed. Since com.openexchange.accesstoken.signin.rateLimit.perLoginTotal is charged before the password is checked, from any address, a third party spreading 50 guesses over enough addresses blocks new devices of that account for a window. Tokens already minted keep working, the web login is not affected, and a user who has proven the second factor is signed in without a further check. Such an attack shows as a rise of appsuite_accesstoken_signin_total with action="signIn" and result="rate_limited" while result="minted" stays flat. To counter it, lower com.openexchange.accesstoken.signin.rateLimit.perIp, so that each address runs out sooner, shorten com.openexchange.accesstoken.signin.rateLimit.timeWindow, so that a blocked login name recovers sooner, or raise perLoginTotal, at the price of more guesses per login name. The counts use the generic rate limiter, Redis-backed where Redis is configured. If it cannot be reached, sign-ins are refused rather than let through.
The counter appsuite_accesstoken_signin_total counts sign-ins of every end-point by action (signIn, confirm) and result (minted, challenged, started, invalid_credentials, rate_limited, second_factor_failed, expired, invalid_request, refused, error).
Enabling it
The bundles ship with open-xchange-core. Tokens are validated by the OAuth provider, which has to be enabled:
com.openexchange.oauth.provider.enabled=true
com.openexchange.oauth.provider.mode=expect_jwt
A deployment without an external authorization server leaves com.openexchange.oauth.provider.jwt.jwksUri empty; the provider then starts without a JWT validator and accepts access tokens only (setting the URI later takes effect after a restart). Where an authorization server is configured, both kinds of tokens are accepted side by side: the access token validator answers for secrets carrying the oxa_ prefix and leaves every other bearer token to the JWT validator. With the provider disabled the module accesstoken is not registered, and minting is refused where the provider is disabled for the user through the config cascade.
The table access_token is created in every context schema by the update task com.openexchange.accesstoken.impl.groupware.AccessTokenCreateTableTask. Rows are removed together with their user or context.
Reaching the mail server
A session made from a token has no password, and a mail request needs a credential to log in to the mail server on the user's behalf. Which one is used is decided without any token-specific setting, from the mail configuration and from the session at hand:
| Mail configuration | Session presenting the token | The mail request logs in with |
|---|---|---|
com.openexchange.mail.passwordSource=global (master authentication) | any | the master password, as for every session |
com.openexchange.mail.authType=xoauth2 or oauthbearer | access token minted from a single sign-on session | the OAuth tokens the vault keeps for the access token, refreshed as they expire |
passwordSource=session (the default) | access token minted from a password session | the password the vault keeps for the access token |
passwordSource=session | access token minted from a session without a password | nothing; mail_access is false and mail requests fail |
In detail:
- A credential kept by the mail credential vault. When a token is minted, the mail credential vault captures what the minting session provides for mail access, under the feature identifier
access-token, and the token stores only the vault's handle. The row is sealed with the token's secret rather than with the server key: the database alone reveals nothing, and only a caller presenting the secret can use the credential. From its first rotation on, the first secret serves as the key, which the token keeps sealed with its current secret. A session made from the token carries the handle and the key, and the vault's mail authenticator serves it like any other vault session. What is kept follows the vault's rules:- With
authType=xoauth2oroauthbearer, a session that holds OAuth tokens, as after a single sign-on login throughcom.openexchange.oidc.*, has its tokens kept. The vault refreshes an access token that is about to expire through the OpenID Connect back-end the session came from, so the token stays usable as long as the identity provider honors the refresh token. The tokens are always the session's own: the token exchange the vault offers other features (com.openexchange.mail.credential.oauth.tokenExchange) is not applied to access tokens, since the vault could not log exchanged tokens out at the identity provider later without the secret it does not hold. - Otherwise a session with a password has that password kept.
- A session with neither, for example a single sign-on session while the mail server expects passwords, or a restricted session, mints a token with
mail_access: false. The token is still good for everything but mail; a mail request with it fails without the bearer token ever reaching the mail server, the secondary mail servers or the Sieve server.
- With
- Master authentication. With
passwordSource=globalandcom.openexchange.mail.masterPasswordset, the middleware logs in with the master password for every session, token sessions included. The vault then records that nothing is needed, somail_accessistrueand mail works without a credential of the user. - A mail server that validates the token itself. With
authType=xoauth2oroauthbearerthe middleware forwards a JWT session's bearer token to the mail server. That does not work for access tokens, which the mail server cannot validate, which is why those use the kept credential instead.
Because only the token's secret opens the credential, the vault cannot rewrite it on its own, which is why a password change through App Suite revokes the user's tokens instead of repairing them; after a password set through provisioning the kept password is stale until the user mints a new token. A rotation of the session encryption key does not affect a sealed row. Revoking a token releases its credential; an expired token's credential expires with it, and a rotation, which gives the token a new secret and a new expiry (see Mobile API), keeps the credential and moves its expiry along.
The kept credential is used for the primary mail account only; secondary and external accounts keep their own stored credentials. Contacts, calendar, files and tasks are not affected; they need no mail server.
Logging
Minting and revoking a token is logged at INFO by com.openexchange.accesstoken.impl.AccessTokenServiceImpl, with the token's identifier, scopes, user and context and, on minting, the vault handle, never the secret. A refused secret is logged at DEBUG only, since an unauthenticated caller can produce one at will; a secret naming a context this server does not know is remembered for a minute, so that probing cannot reach the config database with every request. The session made from a token is an ordinary OAuth session of the provider and appears in its logs with client accesstoken.
A refused sign-in for a token is logged at DEBUG by com.openexchange.accesstoken.json.signin.AccessTokenSignInHandler, with the reason; a failure of the server at ERROR. The login itself appears in the login logs as any other.