Upgrading
This page collects action-required notes for upgrading an existing deployment. Entries are grouped by release, newest first. Routine changes (features, fixes) live in the changelog; anything listed here either requires operator action or deliberately changes behavior.
Next release (after v1.0.0-beta.64)
A user's client-owned grants apply only through that client's tokens
A permission or a role owned by a client (client_id) now applies to a user only through a token issued to that client. Through a token issued to any other client, the server withholds:
- the user's permissions owned by another client, however they are held, directly or through a role (including the ones
adminandrealm_adminreceive when a client-scoped permission is created); - the user's roles owned by another client, together with the global permissions those roles carry.
This applies to:
- request authorization,
POST /authorization/check, and thePOST /permissions/:id/checkandPOST /policies/:id/checkroutes when they check the caller; - the
permissionslist ofPOST /token/introspect, narrowed by the introspected token's own client and never by the caller's; - the
permissionslist ofGET /sessions/@me/introspect, narrowed by the request's own token; - delegation: assigning a role or binding a permission through a token issued to another client is refused when the actor holds the required grants only through the other client.
Requests without a token issued to a client are not narrowed: Basic authentication, a password grant sent without a client, and the served console's session cookie.
Check before upgrading:
- An application that exercised a user's grants owned by another client, or an administrator binding client-scoped permissions through a downstream application's token, now receives
403. Use a token issued to the owning client. - Permissions owned by an API client, used through a separate front-end client. A user signs in through the front end, so the token carries the front end's client, and the permissions the API client owns are withheld, both from requests and from what
POST /token/introspectreports to the API. Own those permissions by the client the users' tokens are issued to, or leave them global. - The admin console hosted standalone, and its
vitedev server, signs in through theadmin-consoleclient, so its tokens are narrowed like any other client's. From there, binding another client's permissions or assigning its roles answers403. Do that work through the console served at<publicUrl>/console/admin, or with Basic authentication. Do not make the grant global to get around it: that lifts the restriction for every application.
Role, scope, permission and policy reads are realm-gated
Both read paths of the four global-capable entities now apply the realmScope of the grant the caller holds. On GET /roles, /scopes, /permissions and /policies that means:
own(the junction default): the caller's own realm only, and not the global (realmId: null) rows, sinceownexcludes null by constructionownOrNull(the reach the built-inrealm_adminholds): the caller's own realm plus the global rowsany(the built-inadmin): every realm, unchanged
Totals and pagination stay exact, and the matching GET /<entity>/<id> refuses a row outside the grant's reach with 403. Previously both paths returned every realm's rows to any caller holding the read permission, whatever its reach. The /policies/:id/expanded read follows getOne.
The change matters most for policies: that list carries each policy's configuration (an attribute query tree, an attribute-name denylist), so it was exposing one tenant's access-control rules to another tenant's administrator.
Two consequences to check before upgrading:
- The
owncase above is the one that bites. Every built-in permission, both built-in roles, theglobalandopenidscopes and the system policies carryrealmId: null, so an operator role you created through the API, which took theowndefault, now sees none of them and its Permissions, Roles, Scopes and Policies pages render empty. Set those grants toownOrNullto restore the global catalogue:POST /role-permissions//user-permissions//client-permissionswithrealmScope: "ownOrNull". The built-inadminandrealm_adminroles are unaffected. - An integration that enumerated other realms' rows through these endpoints stops seeing them. Give it a grant at
anyreach, or query each realm with a credential issued in it.
The admin console needs no change: its four list pages already scope to the active realm plus the global rows.
Checking another subject's permissions requires permission_check
POST /permissions/:id/check and POST /policies/:id/check read who to check for from the identity in the request body:
- Without
identitythe check is about the caller, under the same scope rule as every other route. A bearer whose token lacks theglobalscope is evaluated without an identity, so a permission or policy that needs one answersstatus: "error". - With
identity: nullthe check runs without any identity. - With an identity the check runs for that identity, as sent. Naming the caller's own identity needs nothing more. Naming anyone else requires the new
permission_checkpermission, and the grant's realm scope must reach the realm the subject is stored in (arealmIdin the body does not count). The subject must be named as{ type: 'user' | 'client', id: <UUID> }, since names repeat across realms. A caller without the grant is refused with403, an unknown subject with404, and a malformedidentitywith400. A token withoutglobalneeds the grant even for its own identity. Previously any authenticated caller could name any identity, and since the permission-binding policy loads the grants of the identity it is handed, read another user's or client's authorization in any realm.
A realm- or client-scoped permission is now evaluated as itself; before, the check used a global permission of the same name, or answered that none exists.
The built-in admin role holds permission_check in every realm and realm_admin in its own after the next start, like every provisioned permission. An integration that checks on behalf of other subjects needs a grant of permission_check for its credential.
The device authorization grant is available, opt-in per client
POST /device_authorization and grant_type=urn:ietf:params:oauth:grant-type:device_code at /token implement RFC 8628, with the verification page at <publicUrl>/device (see Device Authorization Grant). Nothing is enabled by the upgrade: a client uses the grant only while the URN is listed in its grantTypes, and a null grantTypes, which allows every other grant, does not enable this one. Review which clients should list it.
The realm discovery document carries device_authorization_endpoint and grant_types_supported (the five grants Authup implements), and mtls_endpoint_aliases carries the device endpoint when mtlsPublicUrl is set. Four codes join ErrorCode: authorization_pending, slow_down and device_code_expired on the poll (each with the matching RFC 8628 error, expired_token for the last) and device_verification_throttled (429) on the verification page. A consumer switching exhaustively over the enum, or a translation catalog keyed by it, needs the four entries.
The render contract of @authup/client-auth-console is version 5. Version 4 renders the /device page, and version 5 has that page complete a login through an identity provider: the payload carries federatedLogin: { providerId }, which the page redeems the way /authorize does and then returns to the code step. A substituted auth console package (AUTH_CONSOLE_PATH) built against an older version is refused at boot; rebuild it against the current contract. A split deployment needs no new proxy rule: /device and /device_authorization sit outside /console and land on the API set, and /console/auth/device follows the console rule (see Console Replicas).
Identity-provider secrets are encrypted at rest
The OAuth2/OIDC clientSecret and the LDAP bind password of an identity provider are stored as cipher blobs under the provider realm's encryption key, the automatically generated per-realm key that already protects MFA seeds and client secrets in encrypted mode. Nothing changes on the API: a reader whose permissions cover the provider (GET /identity-providers/:id) gets the plaintext back, the login and link flows present it to the upstream as before, and the admin console's edit form is unaffected.
A provider saved before this release keeps its plaintext until it is next saved; the value is encrypted on that save, whether the secret changed or not. To finish the migration, open and save each OAuth2, OIDC and LDAP provider once, or update it through the API.
The secret is tied to the key's lifecycle: while the realm's encryption key is disabled, a login through the provider fails (the secret is not recoverable), and DELETE /keys/:id answers 409 while provider secrets reference the key, as it already does for client secrets and MFA seeds; force destroys them and the providers need a new secret. Set secretsEncryptionKey (SECRETS_ENCRYPTION_KEY) so the realm key itself is wrapped at rest.
A hashed client secret is read-gated like a plaintext one
A reader projecting secret (?fields=+secret on /clients, or fields[client] on the client-permission, client-role and client-scope collections) used to receive the bcrypt hash of every client that passed the read pre-gate, foreign realms included. In v1.0.0-beta.64 only a plaintext value (both flags false) was gated per row; the encrypted mode, new in this release, is gated from the start. A hash of an admin-chosen secret is offline-crackable and no reader needs another realm's, so a hashed value now takes that same gate: it is projected on the reader's own client row and on rows the reader's permissions cover, and redacted elsewhere. GET /clients/:id with ?fields=+secret answers 403 for a foreign hashed row, as it already did for a foreign plaintext one. A realm_admin listing clients with the field projected sees the change; an admin does not.
Global permissions, roles, scopes and policies are unique by name
The unique key of auth_permissions, auth_roles, auth_scopes and auth_policies contains a nullable column (realm_id, plus client_id on the first two), and every database treats NULLs as distinct in a unique index, so the key never refused a second row whose tuple holds a NULL: two global rows with one name, and on permissions and roles two realm-scoped rows with one name and no client. Migration 1788782400000-WidenClientSecretAndGlobalUniqueness adds one unique index per table over the same columns with the NULLs coalesced, on MySQL and PostgreSQL, applied by the next boot with migrations enabled or by authup migration run. SQLite keeps the previous behaviour: it never runs migrations, and one database file per container leaves no second replica to race.
Action required only if your deployment already holds duplicate rows. The API has always refused them and the boot is serialised across replicas from v1.0.0-beta.65 on, so the realistic source is a multi-replica first boot on an earlier release, which produced global duplicates. The migration checks every table for a repeated (name, client_id, realm_id) or (name, realm_id) tuple, NULLs included, and aborts naming the affected tables instead of failing on the index. Find the groups:
SELECT name, client_id, realm_id, COUNT(*) FROM auth_permissions GROUP BY name, client_id, realm_id HAVING COUNT(*) > 1;
SELECT name, client_id, realm_id, COUNT(*) FROM auth_roles GROUP BY name, client_id, realm_id HAVING COUNT(*) > 1;
SELECT name, realm_id, COUNT(*) FROM auth_scopes GROUP BY name, realm_id HAVING COUNT(*) > 1;
SELECT name, realm_id, COUNT(*) FROM auth_policies GROUP BY name, realm_id HAVING COUNT(*) > 1;For each group keep one row (the oldest created_at is the usual choice), move every reference onto it, then delete the others. A permission is referenced by permission_id in auth_role_permissions, auth_user_permissions, auth_client_permissions, auth_identity_provider_permission_mappings and auth_permission_policies. A role by role_id in auth_user_roles, auth_client_roles, auth_role_permissions, auth_role_attributes and auth_identity_provider_role_mappings. A scope by scope_id in auth_client_scopes. A policy by policy_id in auth_permission_policies, auth_policy_attributes, auth_role_permissions, auth_user_permissions and auth_client_permissions, by auth_clients.access_policy_id, by auth_policies.parent_id, and by the auth_policy_tree closure table.
Re-point a reference with UPDATE <table> SET <column> = '<survivor id>' WHERE <column> = '<loser id>'. A junction row that then duplicates one that the survivor already holds is refused by that table's own unique key; delete that row instead. Do the re-pointing before deleting a loser: every junction cascades on delete, so deleting a row that still holds references drops them silently.
Client secrets rotate through POST /clients/:id/secret
secretHashed and secretEncrypted are create-time properties now. An update (POST /clients/:id, or PUT /clients/:id on an existing row) ignores both flags; before, flipping secretHashed on a plaintext client marked the plaintext as hashed and the client stopped authenticating. The storage mode changes only through the new endpoint, together with a new secret. ClientUpdatePayload in @authup/core-http-kit no longer carries the two flags, so a caller spelling them on an update fails its type check. Drop them.
An update carrying secret on a hashed client answers 400 naming the endpoint; it used to hash and store the value. Use POST /clients/:id/secret (client.client.rotateSecret(id, { secret?, mode? }) in the SDK). It answers the record plus the plaintext once under meta.secret, accepts @me for a client rotating its own secret under client_self_manage, and records a clientSecretRotated event. A plain client still takes secret on update. See Client Secrets.
Encrypted storage ships in the same release. secretEncrypted: true on create, mode: 'encrypted' on the endpoint and secretEncrypted: true in a provisioning file store the secret as a cipher blob under the client realm's encryption key, the key that already protects MFA seeds, and a reader whose permissions cover the client gets the plaintext back on a read that projects the field (?fields=+secret). Before, the flag encrypted nothing. A row that carries it from an earlier release still holds a plaintext: it keeps authenticating, it is read-gated like any plaintext now instead of being projected to every reader that passed the read pre-gate, and its next rotation without a mode stores the new secret encrypted. Declaring secretHashed and secretEncrypted together answers 400.
auth_clients.secret is widened from 256 to 512 characters by the migration 1788782400000-WidenClientSecretAndGlobalUniqueness, applied by the next boot with migrations enabled or by authup migration run. It is written by hand on both dialects so the values survive; a generated migration would have dropped the column. Reverting it fails while a client holds an encrypted secret (a value longer than 256 characters): rotate such clients to plain or hashed first.
An encrypted secret depends on its realm's encryption key. DELETE /keys/:id on such a key now answers 409 while client secrets reference it, as it did for MFA seeds, and force destroys them: those clients stop authenticating until rotated. Disabling the key has the same effect and is reversible. Set SECRETS_ENCRYPTION_KEY in production so the key material is wrapped at rest (see configuration).
Two smaller changes in the same area. A secret submitted on create or to the endpoint that happens to look like a bcrypt hash is hashed like any other value; it used to be stored as given and could never verify. And a provisioning file that declares secretHashed: true with a plaintext now stores the hash, while a bcrypt value in the file is kept verbatim.
A substituted auth console package is verified at boot again
With a built bundle in place, authup start / authup start console auth refuse to start when the bundle's CONTRACT_VERSION (missing = 1) is not the version this release requires (5), naming the entry and both versions. v1.0.0-beta.64 shipped without the check and rendered such a bundle. Rebuild the substituted package against the current @authup/client-auth-console contract. A missing bundle still answers the actionable per-request error rather than failing the boot.
authup migration generate is refused
The authup binary's migration command accepts run, revert and status. generate is a repository development tool: it drops and recreates the local compose databases and writes the emitted files into the checkout, so outside a checkout it could only destroy a localhost database and produce nothing usable. It now answers Unknown migration operation "generate". Expected one of: revert, status, run. before touching anything. The development route is unchanged: npm run cli -w apps/server-core -- migration generate.
The console services validate their configuration at boot
resolveConfig and readConfigFromEnv of @authup/server-admin-console, @authup/server-account-console and @authup/server-auth-console now run the document schema over the input before layering the defaults, so a value the schema rejects (a non-boolean theme.fragmentsEnabled, a port that is not a number) fails authup start, authup start console and the per-console bin with the offending path instead of reaching the listener as written. Only authup config validate caught those keys before. Validation is asynchronous, so both functions return a Promise now; the authup CLI awaits them, and an embedder calling either directly needs an await.
v1.0.0-beta.64
auth_clients.scope and auth_clients.root_url are dropped
Both columns are gone, and the migration removes them together with their data. scope was a text copy of the client's scope list that nothing has read since #3354: the authorization endpoint grants a client exactly the scopes bound in auth_client_scopes, so a value in the column never granted anything (and writing the column alone was the trap #3347 closed). rootUrl was stored and never resolved against anything. Values set through the API or a provisioning file are lost.
Action required only if you set either. Export them before upgrading if you want to keep them:
SELECT id, name, realm_id, scope, root_url
FROM auth_clients
WHERE scope IS NOT NULL OR root_url IS NOT NULL;A scope list that was meant to grant something belongs in the client's scope bindings (POST /client-scopes, or globalScopes / realmScopes on the client in a provisioning file). A provisioning file that still declares scope or rootUrl on a client keeps loading: the validator strips keys it does not mount, so the values are ignored silently rather than rejected. A request body carrying them is stripped the same way.
baseUrl is the surviving link field and now has a consumer: the account console's Applications page renders each consented application's name as a link to it when the value is an http(s) URL.
User and client updates take a row lock on MySQL and PostgreSQL
POST /users/:id, PUT /users/:id and the client equivalents now perform their write inside a database transaction that locks the row (SELECT ... FOR UPDATE); validation and authorization run before it, outside the transaction. Two concurrent updates of one row used to interleave: each read the row, merged its own fields and saved the whole thing, so the second save silently restored what the first had changed. The reported case was a display-name edit racing an email change and undoing the email_verified reset; a deactivation racing any other edit could be undone the same way. A field the request echoes back with the value it had read is not written either, so a console form saved with a stale active: true no longer undoes a deactivation that landed in between.
No action required. Concurrent updates of one user or client now serialize, so the second waits for the first instead of overwriting it. SQLite is unchanged: it has no row lock, so two updates still interleave there, which is one more reason it is not a production database. Registration, password recovery and the writes a federated login makes to its user are not covered.
Back-channel logout
Authup implements OpenID Connect Back-Channel Logout 1.0. A client gains a nullable backchannelLogoutUri (auth_clients.backchannel_logout_uri, added by the migration; null on every existing row, and the provisioned system clients leave it null). Set it to one absolute http(s) URL (no wildcard, no list) and Authup POSTs a logout_token there whenever a session the client received tokens for is revoked: DELETE /sessions/:id, DELETE /sessions, /logout with a verified id_token_hint, and the refresh-token replay reaction. An expiring session sends nothing.
Discovery advertises backchannel_logout_supported and backchannel_logout_session_supported. The token carries kind: logout_token, a new value in the kind vocabulary next to access_token, refresh_token, id_token and mfa_token; no Authup endpoint accepts it as a bearer, and a consumer switching on kind only ever sees it at its own back-channel endpoint. Delivery is best effort: an application that is down or answers a non-2xx status is logged on the server and the logout succeeds regardless. See the OAuth2 guide.
No action required unless you want the push.
Security fix: a revoked session no longer answers from the cache
Revoking a session (DELETE /sessions/:id, DELETE /sessions, /logout with a verified id_token_hint, the refresh-token replay reaction) removed the row but left the session's cache entry in place, because the cache key was read from the entity after the ORM had already cleared its id. Authup's own API resolves the bearer's session through that cache, so an access token of a revoked session kept working against it until the token expired (15 minutes by default). Refresh was unaffected: the token rows die with the row. The entry is now dropped with the row and the very next request answers 401.
No action required.
start takes a role, and COMPONENTS_ENABLED is now WORKER_ENABLED
start is the one listener command, and the role is its positional argument:
| Command | Runs |
|---|---|
authup start | the API and every enabled console on one listener (unchanged) |
authup start core | the API and the IdP alone, mounting no console |
authup start worker | the background sweeps alone: no listener, no migrations |
authup start console [admin|account|auth] | one console service, or every enabled one, each on its own port |
There is no worker or core subcommand, no top-level console subcommand and no --worker flag. None of them was ever released: all four existed between two betas and were renamed inside that window. A mis-typed role is refused before anything boots: authup start server.core (an unknown role), authup start core admin (a name after a role that takes none), authup start console web (an unknown console). authup start --worker is refused with a message naming authup start worker, rather than parsed as a flag nobody reads and booting the full API in a worker pod.
Action required for anything that runs server/core worker, authup-server worker or authup worker: a Compose command, a Kubernetes Deployment, a systemd unit. Each fails at start with an unknown-command error rather than degrading quietly, so a stale worker stops sweeping. The replacement is authup start worker; in a container, start worker.
The key moved with it. core.componentsEnabled (env COMPONENTS_ENABLED) is now core.worker.enabled (env WORKER_ENABLED). Rename it wherever an API replica set it to false. An old COMPONENTS_ENABLED=false is ignored, so those replicas run the sweeps alongside the worker again. The sweeps are batch-safe, so that is harmless, but it happens N times over. A stale componentsEnabled: key in authup.yml is reported by authup config validate as a path nothing reads.
The key now reads in both modes. Worker mode refuses to boot while core.worker.enabled is false, naming the key in the error, where the old worker role ignored it. A worker that reads the API replicas' WORKER_ENABLED=false therefore stops starting. Give that process WORKER_ENABLED=true in its own environment.
The container command is the CLI's own argument list
The image runs the authup CLI, and the container command reaches it as it stands: docker run authup/authup start, a Compose command: start worker, a Kubernetes args: [migration, run]. The image's default command is start.
The server/core prefix is deprecated. It selected a binary while the image carried several, and there is one now. It is still accepted for the rest of the 1.0.0-beta line, with a one-line notice on stderr, and it is removed in v1.0.0, where server/core start fails as an unknown command. Drop the prefix wherever a Compose file, a Helm values file or a docker run line names it.
client/admin-console start no longer prints a retirement notice of its own. It is an unknown command to the CLI, which prints its usage and exits 1, the same exit code as before. An empty command exits 1 with the usage as well, where it used to exit 1 silently.
PORT and HOST are honored inside the container. The entrypoint used to force HOST=0.0.0.0 and PORT=3000 onto every command, which is why the Docker guide warned that PORT was ignored. They are image defaults now (ENV HOST=0.0.0.0, ENV PORT=3000), so -e PORT=4000 moves the listener and the built-in HEALTHCHECK probes the port PORT names. EXPOSE 3000 is metadata; publish whichever port you set.
email_verified is a real column, and every existing user starts unverified
The OIDC email_verified claim was mapped onto auth_users.active, the account enable flag, which says nothing about the address. It was wrong in both directions: registration sets active outright when EMAIL_VERIFICATION_ENABLED is off (the default), and a federated or provisioned user is created active with a synthesized <name>@example.com, so authup asserted a verified address that had never received anything; conversely, deactivating a user who had completed activation flipped the claim back to false.
It now has its own column, auth_users.email_verified, set by the activation round-trip. The migration backfills false for every existing row, including users who genuinely activated: activate_hash is null both after a completed activation and for a user who was never asked to verify, so no existing row can be proven verified.
Action required if a relying party reads the claim. Anything gating on email_verified: true — account linking by email address is the usual case — stops matching until the addresses are verified again, or vouched for. The field is admin-settable (POST /users/:id with {"emailVerified": true} — the update verb authup serves is POST, not PATCH — and a switch in the admin console's user form), so an operator can restore it for addresses they trust. It is on the system.user-names-self-manage denylist, so a user cannot set it on themselves, and it is cleared automatically when a user's email address changes.
If you were relying on the old behaviour to mean anything, note that it did not: on a default deployment it read true for every self-registered user.
The introspection response omits a claim instead of answering null
POST /token/introspect, GET /sessions/@me/introspect and every id_token mapped nullable user columns straight onto OIDC claims, so a user without a display name answered nickname: null. OIDC models an unavailable claim as an omitted key, so those claims are now absent rather than null. A consumer testing payload.nickname === null should test for absence (== null covers both).
The claims are also declared on OAuth2TokenIntrospectionResponse now (OpenIDClaims in @authup/specs), so a TypeScript consumer reading email, nickname, preferred_username, given_name, family_name or updated_at gets a real type instead of any.
The configuration file is authup.yml
The .conf file family is retired. One file is discovered now, authup.yml (or .yaml, .json, .js, .mjs, .cjs, .ts, .mts), in the working directory or under --configDirectory. --configFile still names one or more explicit files.
authup.conf and authup.server.core.conf are not read any more. A stray one left in the discovery directory is not an error: the server logs one warning at startup and boots on its defaults. Naming one explicitly with --configFile is refused outright, because it would load and then silently drop every key that moved out of the server.core section, leaving the service on a derived issuer and an empty database while the rest of the file applied.
Keys moved as well. Everything a service reads lives in that service's own section (core for server-core), and the deployment-wide options moved up to the top level:
| Was | Is |
|---|---|
server.core.publicUrl | publicUrl |
server.core.db | db |
server.core.redis | redis |
server.core.smtp | smtp |
server.core.trustedOrigins | trustedOrigins |
server.core.themeDirectoryPath | theme.directoryPath |
server.core.themeFragmentsEnabled | theme.fragmentsEnabled |
server.core.adminConsoleEnabled | adminConsole.enabled |
server.core.adminConsolePath | adminConsole.path |
server.core.accountConsoleEnabled | accountConsole.enabled |
server.core.accountConsolePath | accountConsole.path |
server.core.authConsolePath | authConsole.path |
env and rootPath are top-level too. Every other option keeps its name under core.
The shared-section walk is gone with the file family. db, redis and smtp used to be looked up at the top level, under server.* and under server.core.*, with the most specific declaration winning. Each has exactly one place now, the top level.
Action required for a deployment that uses a configuration file. Rewrite it as authup.yml per the table above; see Configuration for the document layout. authup config validate reads the file and the environment and reports what does not hold, a key left at its old location included, so a rewrite can be checked before it is deployed; authup config schema prints the JSON Schema your editor can validate against while you type.
No action for a deployment configured through the environment. No environment variable name changed, so a docker run -e, a Compose environment: block, a Helm values file and a .env are all unaffected.
authup is the operator binary, and it runs the server in process
The authup-server binary is retired. The @authup/server-core package ships no binary at all now; the authup package (npm install authup) carries the operator commands and runs the server inside its own process, where it used to start it as a child and supervise it. (Each console service ships an authup-<name>-console binary as well, for a deployment that runs one console without the CLI; see the console entries below.)
| Was | Is |
|---|---|
authup-server start | authup start |
authup-server worker | authup start worker |
authup-server migration run | authup migration run |
authup-server healthcheck | authup healthcheck |
Containers run the authup CLI underneath, and the container command is its argument list: start, start worker, migration run. The server/core prefix a Compose file, a Helm values file or a docker run line may still carry is deprecated; see the container entry above for its schedule.
Action required for anything that invoked the binary by name: a systemd unit, a PM2 config, a Procfile, a CI step or an npm script naming authup-server. Install the authup package and use the commands in the table. The worker role is on this path as well: authup start worker replaces authup-server worker (see the entry above).
PORT and HOST now follow the normal precedence. The supervisor always forced them onto the child, taking the value from the server.core section of the configuration file (or the defaults) and overriding whatever the environment said. They are ordinary options again, so the layering applies: an environment variable beats the configuration file. That is what a PaaS injecting PORT expects, and it is what every other option already did.
Action required for a deployment that set PORT or HOST in the environment while ALSO naming server.core.port / server.core.host in the configuration file, and relied on the file winning. The two now disagree in the other direction. Drop one of them.
Package selectors are gone. authup start server.core and authup start client.admin-console are refused as an unknown role: the one positional start takes names a role (core, worker, console), never a package. A client.admin-console section in the configuration file is not read (it printed a deprecation warning before). Remove both.
Two smaller things need no action. authup migration run finds its migration files wherever it is started from, so on a normal install the working directory no longer has to be the installed server package. And signal handling reaches the server directly instead of being forwarded: SIGINT/SIGTERM tear the application down and exit with its outcome, a second signal exits immediately, and a teardown outlasting 10 seconds is forced.
The default HTTP port is 3000
core.port defaults to 3000 instead of 3001. This aligns the default with what the container has always done: every published compose file maps 3001:3000, so the process inside has listened on 3000 all along and only the host-side port was 3001.
No action for a Docker deployment, whose port mapping is unchanged, or for any deployment that names core.port (or PORT) explicitly.
Action required for a bare-metal deployment that relied on the default and hard-codes 3001 anywhere a client reaches: a reverse proxy upstream, a publicUrl, a health check. Either set core.port: 3001 to keep the old address, or move those references to 3000.
The consoles are their own services
server-core serves no console any more. Each console is a service package of its own (@authup/server-auth-console for the hosted login, consent, register, activate and password pages, plus @authup/server-admin-console and @authup/server-account-console), and server-core is the protocol surface and the management API. What it keeps under /console is two routes per static console, GET /console/<name>/login/start and GET /console/<name>/callback: they are the cookie-mode sign-in, and the pending-login cookie has to be issued by the origin that reads it back. The six hosted page GETs answer a redirect to the auth console, carrying the request's own query.
start gained two roles for it:
| Command | Runs |
|---|---|
authup start (unchanged default) | the API and every enabled console on one listener |
authup start core | the API and the IdP alone, mounting no console |
authup start console [admin|account|auth] | one console service, or every enabled one, each on its own port (3020 auth, 3021 admin, 3022 account) |
No action for a deployment running authup start: the process, the port, the paths and the container command are all unchanged, and the consoles now run as separate services inside it.
Action required for:
- A split deployment. The flag-only recipe (both sets running
startwith the console flags inverted) no longer produces the intended split, and the flags must now staytrueon both sets. Usestart coreandstart consoleinstead; Console Replicas is rewritten around them, including the two sign-in paths that must keep reaching the API set. - A themed deployment.
theme.directoryPathandtheme.fragmentsEnabledare read by the console services now. In one container nothing changes; in a split one, mount the theme directory into the console containers. - A substituted console.
<name>Console.pathmoved to the console services with the serving. Setting it on an API-only process does nothing. The auth console is themed as well since this release, which is what closes the one window in which the hosted auth pages rendered unthemed.
New options, all per console: url (*_CONSOLE_URL, where the console is published; the path may differ from publicUrl, the origin may not), port (*_CONSOLE_PORT) and host (*_CONSOLE_HOST, inheriting the deployment-wide HOST). Each console service also ships a binary of its own (authup-auth-console, authup-admin-console, authup-account-console) for a deployment that runs a console without the CLI; authup start console is the supported route.
Sub-path deployments. A console url carries the path prefix authup is published under, and the mount subtracts it, so a prefix-stripping proxy reaches the consoles the same way it reaches the API. A console url outside that prefix is refused at startup rather than mounted where no request can arrive.
A console derives its own configuration, and refuses a foreign origin
Every value a console service used to be handed by the CLI is now computed from the document by the console itself: the issuer (publicUrl, derived from core.host and core.port when the document names none), the canonicalized trustedOrigins, its own url, and every path resolved against rootPath. One authup.yml therefore means the same thing whether a console is started by authup start, by authup start console, or by its own authup-<name>-console binary.
Action required for a deployment that starts a console through its own binary AND publishes it on a domain other than publicUrl's. That configuration used to boot and half-work; it now refuses to start, with the key and both urls named. It was already refused when the console was started through the CLI, so only the standalone binary changes behaviour. A console under a PATH of its own is unaffected and remains fully supported.
No action otherwise. Two things stop being errors: a console started standalone without PUBLIC_URL now derives one instead of refusing to start, and a scheme-less trustedOrigins entry (hub.local) now expands to both its http and its https origin for the console as well, where it previously expanded only for server-core. If you added a redundant explicit origin to work around that, it stays harmless.
The consoles moved under /console
Every console server-core serves now lives under one /console prefix on the IdP origin:
| Surface | Was | Is |
|---|---|---|
| Admin console | <publicUrl>/admin (unreleased, see the next entry) | <publicUrl>/console/admin |
| Account console | <publicUrl>/account | <publicUrl>/console/account |
Auth console assets (the scripts and styles behind /authorize, /logout, /register, ...) | <publicUrl>/public/ | <publicUrl>/console/auth/assets/ |
The auth page URLs (/authorize, /logout, /register, /activate, /password-forgot, /password-reset) and every API route are unchanged. They are the protocol surface (authorization_endpoint, end_session_endpoint, the mail deep links) and stay at the root; a GET on one of them now redirects to the auth console at /console/auth, which renders it (see the next entries). /console itself serves nothing.
Action required.
- Rebuild and redeploy every console together with the server. The base path is baked into each bundle's
index.html, and the server mounts the assets under the new path only. A@authup/client-admin-consoleor@authup/client-account-consoledist built for the old base is served without any error and renders a blank console: the shell keeps its oldsrc="/admin/assets/..."hrefs, which nothing answers any more. The published packages and the Docker image of this release carry matching bundles; a package you substitute (ADMIN_CONSOLE_PATH,ACCOUNT_CONSOLE_PATH,AUTH_CONSOLE_PATH) has to be rebuilt with the new vite base (/console/admin/,/console/account/,/console/auth/). - Links, bookmarks and proxy rules naming
/account,/adminor/publicmust name the new paths. That includes the sign-in routes (/console/account/login/start,/console/account/callbackand the admin pair) and every page below a console (/console/account/authenticators, ...). Update the "Manage account" link your own applications render. - No redirect is served for the old paths.
/account/**,/admin/**and/public/**answer404. @authup/client-web-kitand the server must be on the same release. The kit'sbuildConsoleLoginURLnow emits<baseURL>/console/<console>/login/start, a path of its own: the bare/console/<console>/loginis the console's own login PAGE, served by the console service. A newer kit against an older server kicks to a route that does not exist, and an older kit (or an older standalone-hosted console bundle) against this server does the same the other way round; a404on a top-level navigation is unrecoverable.- Standalone hosting: the default
basePatha console assumes is now/console/adminand/console/account. A host may serve the bundle under any other base by injectingbasePath(see Admin Console and Account Console); the same-origin API derivation strips the full two-segment default.
One prefix is what makes serving the consoles from their own replica set a single proxy rule; see Console Replicas.
Also in this release, ACCOUNT_CONSOLE_ENABLED=false applies to the account console's sign-in routes as well: GET /console/account/login/start and GET /console/account/callback answer 404 instead of starting a login, the way the admin console's routes did already. Before, a disabled account console still minted a pending login and a session cookie on a direct hit. No action needed.
The admin console is no longer a Nuxt server
The admin console is a static single-page bundle, served at <publicUrl>/console/admin the way the account console is served at <publicUrl>/console/account (the /console prefix is the entry above). A default deployment still runs one container: authup start runs the API and every console on one listener (see the next entry for what serves what).
Action required.
- Docker / Compose: remove the
client/admin-consoleservice. The entrypoint no longer starts it:client/admin-console startis an unknown command to the CLI, which prints its usage and exits1, so a stale service fails loudly instead of reporting a healthy run having started nothing. The remaining container serves the console. - Helm: the chart in the
authup/helmrepository still deploys an admin console workload, whose pods now crash-loop for the reason above. Remove or scale that workload to zero. A follow-up chart release drops it. - Bare metal: the command does not change.
authup startruns the API and the console in one process.authup start client.admin-consoleis refused and aclient.admin-consolesection of the configuration file is not read (see the entry above). Remove both. TRUSTED_ORIGINS: drop the console's former origin. It serves nothing now, but the provisioner keeps re-asserting every listed origin into the system clients' redirect allowlists on each boot.- Reverse proxy: collapse the two upstreams into one. Under
authup startthe API and every console are served by the same listener, so a rule that routed/to the console port and/api/to the server port routes/to the server port now. See Nginx. - Links and bookmarks: the console moved from the root of its own origin to
<publicUrl>/console/admin.
Retired environment variables, none with a successor: NUXT_PUBLIC_API_URL, NUXT_PUBLIC_PUBLIC_URL, NUXT_PUBLIC_COOKIE_DOMAIN, NUXT_PUBLIC_CLIENT_ID, NUXT_HOST, NUXT_PORT, and the console's build-time names API_URL, API_URL_SERVER, PUBLIC_URL, COOKIE_DOMAIN and CLIENT_ID. The cookie domain is moot: the console is same-origin with the API, so there is nothing to widen. Note that PUBLIC_URL is unaffected as a server-core option; it is the server's own public URL, and the console's address derives from it.
New options. ADMIN_CONSOLE_ENABLED / adminConsole.enabled (default true) serves the console; with it off nothing mounts it and the API's two sign-in routes answer 404. ADMIN_CONSOLE_PATH / adminConsole.path substitutes the console package, the same contract as ACCOUNT_CONSOLE_PATH: a directory holding a built dist/ whose index.html carries the <!--admin-config--> marker. enabled is read by both server-core and the console service; path only by the console service. The features block of the public status endpoint (GET /) gains adminConsole.
Deliberately gone: the Nuxt server process and its published bundle. @authup/client-admin-console ships dist/ only. The authup-admin-console NAME survives in the same release, but as the bin of @authup/server-admin-console, the service that serves that dist (see the consoles-are-services entry above); authup start console admin is the supported route to it. The bundle stays hostable on a static host of your own, on its own origin; see Admin Console.
Sign-in changed as well and needs no action. Served from the API's origin, the console authenticates with the same opaque HttpOnly session cookie the account console uses (GET /console/admin/login/start and GET /console/admin/callback against the per-realm admin-console client), so no OAuth2 token reaches the browser's JavaScript. Hosted standalone on a foreign origin it keeps the browser-side authorization-code flow with PKCE.
Token introspection requires authorization
POST /token/introspect (and its GET form) now answers 401 to a request carrying no credentials, as RFC 7662 section 2.1 requires ("the endpoint MUST also require some form of authorization"). Two forms are accepted:
- a live bearer:
Authorization: Bearer <access token>. The token may be the one being introspected (what@authup/client-web-kitsends) or the caller's own, for instance a resource server's client-credentials token (what the@authup/server-adapter-*packages send, minted on the first401and replayed); - confidential client credentials:
client_id+client_secretin the form body or asAuthorization: Basic, or atlsclient's certificate. A public client (authMethod: none) is refused withinvalid_client; its bareclient_ididentifies it but proves nothing.
Authentication is the first layer. The second is WHOSE tokens the caller may introspect: the caller's own (the subject matches), tokens issued for the caller's own client (the token's client_id), or any token reached by the new token_introspect permission (realm-scoped: an admin reaches everything, a default grant covers the client's own realm). A caller failing all three receives a bare {"active": false}, as RFC 7662 section 2.2 prescribes for a resource "not allowed to introspect". The server logs the denial, since the response is indistinguishable from a dead token by design.
Action required for resource servers using @authup/server-adapter-* remote verification: grant token_introspect to the client behind the verifier's creator, or foreign tokens will verify as inactive. A downstream application introspecting tokens issued to its own client needs no grant.
The expired-token report (next section) is gated the same way: it is reachable only by a caller that proved who it is and may introspect that token. An expired token is NOT a credential: the authorization middleware rejects an expired bearer before the endpoint runs, so a request whose only token is the lapsed one answers 401.
Action required for an integration that called the endpoint anonymously: send one of the two credentials. Nothing changes for @authup/client-web-kit (it introspects its own token). For the @authup/server-adapter-* packages the credential replay flow is preserved, but verifying foreign tokens now additionally requires the token_introspect grant described above. POST /token/revoke stays open as a deliberate authup choice: RFC 7009 asks a public client to identify itself by client_id and the server to verify token ownership, but a public client_id proves nothing, and possession of the token already lets its holder use it. Revoking is the benign action.
Docker: the image follows the FHS (/opt/authup, /etc/authup)
The built tree moved from /usr/src/app to /opt/authup, and the configuration file is read from /etc/authup instead of the working directory. One mount per concern: code under /opt, configuration under /etc, log files under /var/log. There is no state directory any more (see the next entry).
Action required if you mount a configuration file. Mount it at the new path; the old one is not read any more.
volumes:
- - ./authup.yml:/usr/src/app/authup.yml
+ - ./authup.yml:/etc/authup/authup.ymlMiss this and nothing fails loudly: the container boots on its defaults. A rootPath in that file that spelled /usr/src/app needs the same change. No release read /usr/src/app/authup.yml (the v1.0.0-beta.63 image read the retired authup.server.core.conf family from its working directory), so a deployment moving to authup.yml changes the name and the mount path at once.
writableDirectoryPath is replaced by two purpose-named keys
The key named one directory for two things that sit on opposite sides of a trust line: the production log files the process writes, and the provisioning files an operator authors and the process only reads. It is gone, replaced by one key per concern.
| Was | Is | Environment variable | Default |
|---|---|---|---|
writableDirectoryPath (log files) | logDirectoryPath | LOG_DIRECTORY_PATH | logs |
writableDirectoryPath (provisioning) | provisioningDirectoryPath | PROVISIONING_DIRECTORY_PATH | provisioning |
Both resolve against rootPath when relative, exactly as the old key did. logDirectoryPath is now the only directory the process writes to, so the provisioning directory can be mounted read-only (:ro).
Provisioning is read from the new key directly, not from a provisioning subdirectory of it. The old key appended one; this one names the directory itself. A deployment whose files sit at <rootPath>/writable/provisioning either points PROVISIONING_DIRECTORY_PATH at that path or moves them to <rootPath>/provisioning.
Action required if you set the old key or use file-based provisioning.WRITABLE_DIRECTORY_PATH is no longer read, and neither is a writableDirectoryPath in authup.yml.
In the image, provisioning moved to /etc/authup/provisioning (next to the configuration file it belongs with) and the log files to /var/log/authup. The /var/lib/authup volume is gone. Nothing durable was ever in it: the database is postgres or mysql, the signing and encryption keys are auth_keys rows, the cache is redis, so it held http.log and error.log and nothing else. Drop the mount, and mount the provisioning directory at its new path.
volumes:
- - authup:/var/lib/authup
- - ./provisioning:/var/lib/authup/provisioning
+ - ./provisioning:/etc/authup/provisioningA mount kept at /var/lib/authup is not an error. It silently does nothing. From the release after v1.0.0-beta.64 on, a provisioning directory the new key does not find is reported once at boot, as an info line naming the resolved path (Provisioning directory ... does not exist; file-based provisioning is skipped.); it is not refused, since provisioning is optional and the image points the key at a directory it does not create. The log files are written inside the container layer unless /var/log/authup is mounted, which is the ordinary posture for a container that logs to a collector.
Authup boots on SQLite when no database is configured
Outside production, a process started with no database configuration now falls back to the better-sqlite3 driver and writes db.sqlite into the working directory, instead of exiting with "The database configuration could not be read from env variables.". That is what the db configuration key and the Database guide have both described for some time, while nothing implemented it, so no surface booted unconfigured. npx authup@latest start now works with no configuration at all.
Production is unchanged. better-sqlite3 is still refused when the environment is production, which is what the Docker image sets, so a container still requires PostgreSQL or MySQL. That refusal now names the variables to set instead of reading as a generic environment failure.
authup migration run and the two CI runners under apps/server-core/scripts/ are deliberately not covered and still fail when nothing is configured. SQLite runs no migrations, so a fallback there would create a database file, report "No migrations are pending" and exit 0.
Action required only if you relied on the crash as the signal that database configuration was missing in a non-production environment. Such a process now starts against an empty local SQLite file rather than exiting.
The seeded development origin moves to vite's default port
DEVELOPMENT_ORIGIN, the origin appended to trustedOrigins outside production, changes from http://localhost:3010 to http://localhost:5173. The admin console's dev server no longer pins a port, so it takes vite's default like the account console, and both now pin strictPort so a taken port fails loudly instead of shifting to an origin nothing trusts.
This makes the account console's standalone dev server work with no configuration for the first time: it was already on 5173, which was not the seeded origin, so its sign-in round-trip was refused unless TRUSTED_ORIGINS named it by hand.
Only one standalone console can hold the port. Run the other through authup dev, which serves every console on the API's own origin and needs no seeded origin at all, or give it its own TRUSTED_ORIGINS entry.
Action required only if you run a console dev server on :3010 (a non-production deployment trusted that origin automatically and no longer does). Set TRUSTED_ORIGINS=localhost:3010, or move to the default port.
v1.0.0-beta.63
Introspecting an expired token now returns its payload
POST /token/introspect answers 200 with "active": false and the token's payload and subject claims for an expired token, where it previously raised 401. A relying party can now tell the person whose session ended who they were - "your session expired, Alice" - instead of only that something failed.
A token the server cannot read at all - malformed, a bad signature, a kid naming no known key - is also reported now, as a bare {"active": false}. It answered 401 (or 404) before. RFC 7662 section 2.2 requires this: a token that "does not exist on this server" is reported, not raised. A missing tokenparameter is still a malformed request and still answers 400.
permissions is no longer returned for an inactive token, expired ones included. It names who the token belonged to, not what they were allowed to do. A caller that read permissions off an introspection response must check active first - which it should have been doing regardless.
Token revocation answers 200, and answers it for invalid tokens too
POST /token/revoke returned 202. It now returns 200, the status RFC 7009 section 2.2 names. Both are 2xx, so a client checking the status class is unaffected; a client comparing against 202 exactly is not.
The same 200 is now returned for a malformed or unverifiable token, per the same section - "invalid tokens do not cause an error response since the client cannot handle such an error in a reasonable way". Expired tokens already behaved this way. The point of the rule is that an invalid token is indistinguishable from a revoked one, so do not expect to detect a bad token from the response.
Action required if you branch on the failure. A client that treated a 401 from introspection as "the token is dead" now has to read active, which is the field RFC 7662 defines for it.
@authup/client-web-kit is updated in step: its store refuses to commit a session for a response reporting active: false, which it previously ignored - so a revoked or expired token restored from cookies rendered as authenticated until the next protected request failed. Upgrade the kit alongside the server.
A bearer token whose key is unknown answers 401, not 404
An access token whose kid names no key, an encryption key, or a disabled key was reported as 404 jwk_not_found on every route. It is now 401 with the invalid_token code, which is what a resource server expects from a credential it cannot verify. The practical effect is that clients recover from a key rotation instead of treating it as a missing resource.
401 responses from protected routes carry WWW-Authenticate
Per RFC 6750 section 3, a 401 from a protected resource now carries WWW-Authenticate: Bearer error="invalid_token", error_description="...", or a bare Bearer when the request presented no credentials at all. The token endpoint's own 401 (invalid_client, RFC 6749 section 5.2) deliberately does not carry the header, since it is not a bearer failure. Purely additive.
Identity provider updates keep attributes they do not mention
POST /identity-providers/:id replaced the provider's whole extra-attribute set, so an update that said nothing about a key deleted it. Automation written before requiredAmr / requiredAcr existed therefore turned the upstream assurance gate off just by updating an OAuth2 provider. A partial update now keeps attributes it never mentioned. Send an attribute as null to clear it. Changing a provider's protocol still replaces the set, so the old protocol's configuration (including its secret) does not linger.
The identity provider assurance gate checks the id_token audience
With requiredAmr or requiredAcr set, the upstream id_token must now carry the provider's clientId as its only audience, and an azp, if present, must name it too. OIDC Core section 3.1.3.7 item 3 rejects a token listing any audience the client does not trust, and authup has no trusted-audience setting, so the client's own id is the only trusted one.
Providers with neither allow-list set are unaffected, and a conformant OIDC provider issuing a token for one relying party satisfies this already. An upstream that mints one id_token for several audiences at once will start failing logins when you opt into assurance.
Docker: the writable directory moved to /var/lib/authup
Superseded by the next release, and the mount and variable below are dead there. The writable directory was split into a log directory and a provisioning directory, WRITABLE_DIRECTORY_PATH is not read any more, and the image keeps no state directory at all. Upgrading from before this release to a newer one means following the FHS entries above instead of this one; it is kept because it is what the v1.0.0-beta.63 image does.
The image wrote its runtime files to /usr/src/app/writable, inside the application install directory. It now uses /var/lib/authup, which is where the filesystem hierarchy standard puts mutable application state and which is a cleaner mount point than a path nested in the install tree.
Action required if you mount a volume at the old path. Update the mount target; the volume itself is unchanged.
volumes:
- - authup:/usr/src/app/writable
+ - authup:/var/lib/authupThe same applies to a bind-mounted provisioning directory: -v /path/to/provisioning:/var/lib/authup/provisioning.
Miss this and nothing fails loudly: the container starts, writes its production log files inside the container layer instead of the volume, and finds no provisioning files, so file-based provisioning silently stops being applied. Set WRITABLE_DIRECTORY_PATH=/usr/src/app/writable to keep the old location instead.
Only the image default changed. Running outside Docker still defaults to writable relative to the application root, so an unprivileged npx or bare-metal start is unaffected.
A relative writableDirectoryPath now resolves against rootPath
The documented behavior - a relative path is resolved against rootPath - was implemented for themeDirectoryPath, authConsolePath and accountConsolePath but not for writableDirectoryPath, which stayed relative to the process working directory and ignored rootPath entirely. It now resolves like its siblings.
This only changes behavior for a deployment that sets rootPath to something other than the working directory and gives writableDirectoryPath a relative value. Absolute values, and the default, are unaffected.
GET /identity-providers/:id now requires authentication
The endpoint was anonymous. It now requires an identity holding one of IDENTITY_PROVIDER_READ, IDENTITY_PROVIDER_UPDATE or IDENTITY_PROVIDER_DELETE for that provider's realm. A request without a token answers 401; an authenticated caller lacking the permission answers 403 with code permission_evaluation_failed.
The record read carries the provider's extra attributes, which for an OAuth2 provider include clientSecret and for an LDAP provider the bind password. The previous behavior handed those to anyone who knew a provider id.
Action required only if you read a single provider without a token. The collection stays anonymous, because the hosted login page lists providers before anyone signs in, and it never carries the extra attributes. Read the one provider from it instead:
GET /identity-providers?filter[id]=<provider-id>
GET /identity-providers?filter[name]=<provider-name>Both key forms the record route accepted are filterable, so a caller addressing a provider by name has a substitute too.
v1.0.0-beta.62
auth_identity_provider_accounts gains a unique constraint
(provider_id, provider_user_id) becomes unique, so one external identity belongs to exactly one local user. Until now that invariant was enforced only by a read-then-write in the application, with no transaction and no row lock, so two concurrent logins or link completions for the same upstream subject could both insert. After that the subject resolved to whichever row the database happened to order first.
Action required only if your deployment already holds such duplicates. The migration checks first and aborts the boot with the number of affected groups rather than a hash-named driver error. Find them with:
SELECT provider_id, provider_user_id, COUNT(*)
FROM auth_identity_provider_accounts
GROUP BY provider_id, provider_user_id
HAVING COUNT(*) > 1;Keep the row whose user_id names the account the person actually uses, and delete the rest. The unique index cannot be created while duplicates exist, so the boot would fail either way; the check only makes the reason readable.
Federated login: completion hardening
- A federated login must carry the authorization code request it completes.
GET /identity-providers/:id/authorize-outrefuses a request withoutcodeRequest(invalid_request). SDK consumers building their own login page pass it through the client:client.identityProvider.getAuthorizeUri(id, { codeRequest }); the baregetAuthorizeUri(id)URL no longer starts a login on its own. - The callback completes the request by redirecting the browser to the client's own
redirect_uriwithcodeandstate(RFC 6749). A refusal after the callback lands on the hosted login page instead of a JSON body, and a provider answering witherror(the user cancelled) does the same. - A custom-scheme
redirect_uri(myapp://cb, RFC 8252) is served through an interstitial page that launches the app. Script-capable and local schemes (javascript:,data:,vbscript:,blob:,filesystem:,file:,about:) are refused everywhere: as a client redirect pattern, at/authorize, and at the callback. - A
redirectUri/postLogoutRedirectUripattern carrying userinfo (https://user:pass@app/**) is refused by the client validator, as is aredirect_uricarrying one at/authorizeand/logout. An existing client row holding such a pattern cannot be saved again until the pattern is fixed, and a provisioning file declaring one fails the boot with the file path and the issue. - The pending-login state a federated login or an account link carries moved to its own cache namespace, so a login or link started on the previous version and completed after the upgrade is refused (
invalid_request, up to the 30 minute state lifetime); the person starts it again. Relevant for a rolling upgrade over a shared Redis only. - The identity-provider form's read-only "Redirect URL" now shows the callback (
/identity-providers/<id>/authorize-in), the value to register at the external provider. It used to showauthorize-out, which is the URL that starts a login, not the one the provider redirects back to. - A substituted auth console package (
AUTH_CONSOLE_PATH) must be built against render contract version 2, which adds the interstitial route/identity-providers/:id/authorize-inand itsIdentityProviderCallbackPayload; a package exporting an olderCONTRACT_VERSIONis refused at boot.
v1.0.0-beta.60
Fixed: external identity-provider login
The token exchange against an external OAuth2 / OIDC provider never sent the code parameter, so every federated login has failed since v1.0.0-beta.28 with an invalid_request from the provider's token endpoint. The callback passed the raw code string where the authenticator expected { code }, and the HTTP client spread it into indexed body keys (0=d&1=b&...). No action is required beyond upgrading. A callback that arrives without a code is now rejected with a 400 instead of an opaque upstream failure.
auth_sessions.client_id is the client-subject foreign key only
One browser session legitimately serves several applications (the hosted auth pages and the account console share the IdP origin by design), so a single column could never name all of them: it was last-writer-wins and accurate for none. Per-application attribution moved one level down, onto auth_session_tokens.client_id.
auth_sessions.client_id now means what its foreign key always implied — the subject of a client session — and is NULL for a user's session. Nothing writes the authorizing application there anymore.
- Runbooks that sign an identity out of one application must change:
DELETE /sessions?filter[clientId]=...no longer matches user sessions. UseDELETE /session-tokens?filter[clientId]=..., which revokes that application's tokens and leaves the session alive so the others stay signed in. To sign an identity out everywhere, filter byuserIdinstead. - Sessions created before the upgrade keep the old value. No backfill ships, because the column is behind
ON DELETE CASCADE: until those rows expire (session lifetime, three days by default), deleting a retired client still force-logs-out the users whose sessions recorded it. Either wait out the lifetime before deleting a client, or runUPDATE auth_sessions SET client_id = NULL WHERE sub_kind <> 'client'.
Schema migration: constraint names, MySQL column widths, 140 indexes
Two migrations apply. Both are safe to run on a populated database, and the round trip is verified in CI against MySQL and PostgreSQL, but the second one is not instant on a large instance. Run authup migration run before the rolling restart rather than letting the first pod apply it under traffic.
- Index, unique and foreign-key names move onto the values TypeORM derives from the entity metadata. Names have no runtime meaning; this only ends a split where two naming regimes coexisted and every generated migration emitted 32 renames before its actual change.
- MySQL only: 15
uuidcolumns widen fromvarchar(36)to thevarchar(255)TypeORM derives for them. This rewrites those tables, blocks writes for the duration and needs disk headroom for the copy. It runs first and is re-runnable, so an interrupted upgrade retries cleanly. - 140 indexes are added, backing the filter and sort vocabulary each entity advertises (see below) and the remaining foreign-key columns. On PostgreSQL the migrations run in one transaction, so the largest tables (
auth_events,auth_sessions,auth_session_tokens) are locked for the duration of the build. - Three orphaned tables are dropped with their rows:
auth_authorization_codes,auth_refresh_tokensandauth_identity_provider_roles. Each was superseded years ago (codes moved to cache blobs, refresh tokens toauth_session_tokens, provider roles toauth_identity_provider_role_mappings) and no entity has described them since. Back them up first if you still read them out of band.
API: meta.schema.sort is now meta.schema.sorts
The query-capable GET endpoints describe their vocabulary under meta.schema. Its sort key was renamed sort → sorts (rapiq 2.1.0, which made sorts canonical on every developer-authored surface). A client that reads meta.schema.sort must follow; there is no alias. The ?sort=-name URL parameter is unchanged.
Filters and sorts are also index-anchored now: every key an endpoint allows is backed by a real index, so single-key filters and sorts behave exactly as before. The one narrowing is a multi-key sort with no matching composite index prefix, which is now dropped whole rather than executed — the request still succeeds, unsorted.
v1.0.0-beta.59
Security fix: redirect patterns with a host wildcard
A redirectUri / postLogoutRedirectUri pattern carrying a * in its host (https://*.example.com/**) matched more than its host. The matcher treats / as its only boundary, while a URL authority also ends at ?, # and \, so https://evil.test?.example.com/cb matched the pattern above and an authorization code was issued to evil.test.
The candidate is now canonicalized before matching, so the string that gets authorized is the string the browser navigates to. No action is required, and no legitimate redirect stops matching. Three side effects are worth knowing:
- Both the request and the stored pattern are normalized before comparison, so a host differing only in case, an explicit default port (
:443on https) and./..segments resolve on either side. A path-scoped pattern can no longer be walked out of with.., and a pattern that was written non-canonically (https://APP.example.com/**,https://app.example.com:443/**) now matches the requests it always looked like it should. **is no longer accepted inside the host of a pattern. It matches the rest of the value outright, sohttps://**.example.com/**read as "any subdomain" but accepted every origin. A single*is unchanged: it matches any run of characters that does not cross a/, so it spans dots andhttps://*.example.com/**covershttps://a.b.example.com/cbas well ashttps://a.example.com/cb. A stored pattern is not rewritten; only new writes are rejected.TRUSTED_ORIGINSrejects the same shape at startup, with a message naming the offending value.
client/web was renamed to client/admin-console
The rename of the admin console app (@authup/client-web → @authup/client-admin-console) reaches the operator surface. There is no backwards alias, and an unknown selector is now a hard error instead of a silent success:
- Docker:
docker run authup/authup client/web start→client/admin-console start. The entrypoint used to exit0on an unknown service, so this previously looked like a healthy container that started nothing. It now exits1. - Launcher config: a
client.websection inauthup.confis no longer read. Rename it toclient.admin-console, otherwise every key in it (port,host,apiUrl,cookieDomain) silently falls back to its default. - CLI:
authup start client/web→authup start client/admin-console. - Binary:
authup-ui→authup-admin-console.
admin-console and account-console are reserved client names
Both names are now provisioned as system clients in every realm. If a client of either name already exists, it is taken over rather than left alone: the provisioner overwrites name, realmId, authMethod, tokenBindingMethod, builtIn, active, grantTypes, scope, redirectUri and postLogoutRedirectUri, which makes it a public (secret-less) auto-consenting client.
Rename any existing client on those names before upgrading. Attributes outside that list (displayName, description, accessPolicyId, junction rows) are preserved.
The per-realm web client was removed
The shared web system client is no longer provisioned. Authup's own consoles were moved off it earlier (admin-console / account-console); it existed purely for downstream applications and was default-on attack surface (auto-consent + global scope in every realm).
Existing web rows are not touched: logins against them keep working. Two behavior changes require action:
PUBLIC_URL/TRUSTED_ORIGINSchanges no longer propagate to the leftover rows; theirredirectUri/postLogoutRedirectUriare frozen as-is.- Realms created after the upgrade get no
webclient, so aclient_id=weblogin breaks there.
Register a client of your own instead. To keep the every-realm semantics, declare it once via a wildcard realm entry (realms[].attributes.name: "*"), which also offers a declarative absent cleanup for the leftover web rows. CLIENT_WEB_NAME was removed from @authup/core-kit, and web is a regular, creatable client name again.
v1.0.0-beta.52 (was: next release after v1.0.0-beta.51)
Login redirect allowlist — set TRUSTED_ORIGINS
Interactive login runs through the authorization-code flow against a per-realm web client whose redirect allowlist is built from the origin of PUBLIC_URL plus every entry in TRUSTED_ORIGINS (renamed from ADDITIONAL_DOMAINS).
If your web UI is served from a different origin than PUBLIC_URL (e.g. UI on https://app.example.com, server on https://auth.example.com) you must set:
TRUSTED_ORIGINS=app.example.comOtherwise every login fails after the redirect with an opaque OAuth2 error. Entries may be bare hosts (expanded to both http and https) or full origins; comma-separated.
Security
Each trusted origin is added to a builtIn (auto-consent) client carrying the global scope — an allowlisted origin can obtain full-permission user tokens. Only list origins you control.
Refresh-token rotation (hard cutover)
Every refresh_token grant now rotates: the presented token is retired and a fresh pair is issued. Refresh tokens minted before the upgrade are rejected with invalid_grant — active users have to sign in once after the upgrade. Replaying an already-consumed refresh token revokes the whole session family (RFC 6819 §5.2.2.3). A multi-tab tolerance window can be configured via TOKEN_REFRESH_GRACE_PERIOD (seconds, default 0 = strict).
Access-token TTL default lowered: 3600 → 900 seconds
Shrinks the revocation blind spot for stateless JWKS-verifying adapters. Override via TOKEN_ACCESS_MAX_AGE (seconds) if your deployment depends on longer-lived access tokens.
Authorize & token flows are realm-bound
An identity can only authorize (or redeem a code / refresh a token) against a client in its own realm — a cross-realm attempt yields login_required / invalid_grant. Consequences:
- Master-realm admins can no longer sign into other realms' applications through the built-in
webclient; use an identity of the target realm. - A client identified by name at
/authorizenow requires a realm hint (realm_id), since every realm has awebclient.
Implicit & hybrid response types removed
response_type=code is the only supported response type (OAuth 2.1 posture). Public clients must use PKCE and state unconditionally; the id_token is minted at the /token exchange. RPs still using token / id_token / hybrid response types must migrate to the code flow.
RP-initiated logout & discovery corrections
- New
end_session_endpoint:GET/POST /logout(advertised in the realm discovery document). Downstream apps should end the shared authup session by redirecting there with anid_token_hint. post_logout_redirect_uriis honored only when it matches the client's new, separatepost_logout_redirect_uricolumn — a URI matching only the loginredirect_uriis rejected. Set the column for clients that use post-logout redirects (the provisionedwebclient is populated automatically).- Discovery
revocation_endpointwas corrected from…/tokento…/token/revoke(RFC 7009).
Minimum password length raised to 10
New password writes (user create/update, registration, password reset) reject values shorter than 10 characters — configurable via PASSWORD_MIN_LENGTH. Existing password hashes keep verifying; nobody is forced to reset. File-provisioned users whose configured password is shorter than the floor now fail provisioning validation at startup.
Client.grantTypes is now enforced
A non-null grantTypes value (space- or comma-delimited) acts as an allowlist at the /token grants and the /authorize code request; violations fail with unauthorized_client. null keeps allow-all semantics, so only clients that set the column are affected — review clients that stored decorative values (e.g. a client listing only authorization_code will fail token refreshes until refresh_token is added). The provisioned per-realm web client already lists authorization_code refresh_token.
Also notable (no action required)
- Session management:
GET/DELETE /sessionsAPI plus a sessions UI ("This device" marker, "log out other devices", admin force-logout). - OIDC prompt surface:
/authorizesupportsprompt(none|login|consent|select_account),max_ageandlogin_hint; the@authup/client-web-kitURL builder now defaults toprompt=select_account, so kit-based apps inherit an account chooser on lingering sessions (pass an explicitpromptto opt out). - id_token claims:
auth_timenow reflects the real authentication instant and asid(session id) claim is included.