Skip to content

Feat: Add application passwords expiry - #11685

Open
DarkMatter-999 wants to merge 13 commits into
WordPress:trunkfrom
DarkMatter-999:53995-feat-add-application-password-expiry
Open

DarkMatter-999 wants to merge 13 commits into
WordPress:trunkfrom
DarkMatter-999:53995-feat-add-application-password-expiry

Conversation

@DarkMatter-999

Copy link
Copy Markdown

This PR introduces the ability to set and manage expiration dates for Application Passwords in WordPress. Currently, application passwords are permanent until revoked; this change enhances security by allowing users to define a "Time to Live" (TTL) for specific credentials.

Key Changes:

  • User Interface: Added an "Expires on" date picker to the "Add New Application Password" form.
  • Management: Added an "Expires" column to the Application Passwords list table.
  • Inline Editing: Implemented AJAX-powered inline editing for expiration dates directly from the list table, including "Save" and "Cancel" functionality.
  • REST API: Updated the application-passwords controller to support the expires field for both creation and updates.
  • Authentication: Updated wp_authenticate_application_password to validate the expiration timestamp. If a password has expired, authentication will fail with an application_password_expired error.
  • Tests: Added e2e tests and unit tests to cover creation with expiration, inline editing of expiration dates, and ensuring expired passwords display correctly.
application_password_expiry.mp4

Trac ticket: https://core.trac.wordpress.org/ticket/53995

Use of AI Tools

AI assistance: Yes
Tool(s): Gemini/Antigravity
Model(s): Gemini 3 Flash, Gemini 3.1 Pro
Used for: Troubleshooting E2E test failures and drafting the PR description based on provided code diffs.


This Pull Request is for code review only. Please keep all other discussion in the Trac ticket. Do not merge this Pull Request. See GitHub Pull Requests for Code Review in the Core Handbook for more details.

@github-actions

Copy link
Copy Markdown

Test using WordPress Playground

The changes in this pull request can previewed and tested using a WordPress Playground instance.

WordPress Playground is an experimental project that creates a full WordPress instance entirely within the browser.

Some things to be aware of

  • All changes will be lost when closing a tab with a Playground instance.
  • All changes will be lost when refreshing the page.
  • A fresh instance is created each time the link below is clicked.
  • Every time this pull request is updated, a new ZIP file containing all changes is created. If changes are not reflected in the Playground instance,
    it's possible that the most recent build failed, or has not completed. Check the list of workflow runs to be sure.

For more details about these limitations and more, check out the Limitations page in the WordPress Playground documentation.

Test this pull request with WordPress Playground.

@DarkMatter-999
DarkMatter-999 marked this pull request as ready for review April 30, 2026 08:53
@github-actions

github-actions Bot commented Apr 30, 2026 •

Copy link
Copy Markdown

The following accounts have interacted with this PR and/or linked issues. I will continue to update these lists as activity occurs. You can also manually ask me to refresh this list by adding the props-bot label.

Core Committers: Use this line as a base for the props when committing in SVN:

Props lakshyajeet, peterwilsoncc, faisalahammad.

To understand the WordPress project's expectations around crediting contributors, please review the Contributor Attribution page in the Core Handbook.

@peterwilsoncc

Copy link
Copy Markdown
Contributor

During manual testing, I am seeing the following issues:

  1. Date picker time is not server time

The date picker pop-over shows the local/computer time rather than the website time. This can result in strange behaviour when setting the time outside of the servers timezone.

  1. Set server time to UTC
  2. Move to a positive time zone (I recommend Australia/Melbourne +1000)
  3. Edit expiry of a application password
  4. Select current date (28 Sept in Melbourne)
  5. Expiry date is recorded as the day earlier (27 Sept)

Reproduction steps may be time of day dependent -- whether the local and UTC date are different at the time of reproduction

date-time
  1. It's possible to set the expiry date to the past.

I was able to set an expiry of both a new and existing app password to be in the past. It may be useful for existing ones but setting the date to the past for a new one should prevent it from being created.

  1. Clearing expiry dates is not possible

It's possible to add an expiry date to an application password that doesn't have one but the reverse is not true.

@DarkMatter-999

Copy link
Copy Markdown
Author

Hi @peterwilsoncc, thanks for testing it out. I have updated the code to support:

  • Fix server time and local time descrepency
  • Prevent setting a past expiry date
  • Add clearing expiry dates

@peterwilsoncc peterwilsoncc left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think I have a better understanding of what was happening last time I tested with the expiry time.

In addition to the date/time offset being incorrect, the expiry time was automatically set to midnight. This resulted in setting an expiry date to today being expired.

I don't think the expiry date needs a time in the date picker. Instead, it can default to 23:59:59 on the date selected. This matches how a credit card expires (an expiry of 01/27 will expire on the 31st of January 2027).

There's a couple of PHP Stan warning that have been posted inline that look correct.

For one, it's a matter of adding the new value to this array

* @type array ...$0 {
* @type string $uuid The unique identifier for the application password.
* @type string $app_id A UUID provided by the application to uniquely identify it.
* @type string $name The name of the application password.
* @type string $password A one-way hash of the password.
* @type int $created Unix timestamp of when the password was created.
* @type int|null $last_used The Unix timestamp of the GMT date the application password was last used.
* @type string|null $last_ip The IP address the application password was last used by.
* }

For the other, it appears to be an incorrect comparison.

@faisalahammad

Copy link
Copy Markdown

Test report

Tested at 729ceae.

Environment:

  • PHP 8.5, MariaDB, npm run build:dev from this branch
  • Site timezone Australia/Melbourne (UTC+11), browser timezone Asia/Dhaka (UTC+6)
  • Tested while the site date (Oct 18) and the UTC date (Oct 17) were different, which is the case Peter described

Timezone (Peter's report)

The offset problem looks fixed. The browser and the site were in different timezones and the stored values were all correct:

Action in UI (site time) Sent to REST Stored / shown
Create, expires 2026-10-18 23:59:59 2026-10-18T12:59:59.000Z "October 18, 2026 11:59 pm"
Edit, expires 2026-10-25 23:59:59 2026-10-25T12:59:59.000Z "October 25, 2026 11:59 pm"
Edit, prefilled value n/a 2026-10-18T23:59:59 (site time, correct)
Remove expiry {"expires":null} "—"
Create, expires 2026-10-18 00:00:00 (past) blocked by min no request sent
Already expired (set with PHP) n/a "Expired on October 18, 2026 4:01 am"

Peter's second point is still open. The field is datetime-local with step="1", so users still have to pick a time down to the second. Peter suggested a date-only field that expires at 23:59:59 site time on the chosen day, the way a credit card expiry works. That would also remove the "set today, already expired" trap.

Bug: updating through REST without expires clears the expiry

prepare_item_for_database() always sets $prepared->expires = null. update_application_password() then sees the expires key and saves null. So any PUT that only changes name silently removes the expiry.

Steps:

  1. POST /wp/v2/users/me/application-passwords with name=App&expires=<a future date>
  2. PUT /wp/v2/users/me/application-passwords/<uuid> with only name=Renamed
  3. expires is now null

Test that fails on this branch:

public function test_update_without_expires_keeps_expiry() {
	wp_set_current_user( self::$admin );

	$request = new WP_REST_Request( 'POST', '/wp/v2/users/me/application-passwords' );
	$request->set_body_params(
		array(
			'name'    => 'App',
			'expires' => gmdate( 'Y-m-d\TH:i:s', time() + WEEK_IN_SECONDS ),
		)
	);
	$uuid = rest_get_server()->dispatch( $request )->get_data()['uuid'];

	$request = new WP_REST_Request( 'PUT', '/wp/v2/users/me/application-passwords/' . $uuid );
	$request->set_body_params( array( 'name' => 'Renamed' ) );
	rest_get_server()->dispatch( $request );

	$item = WP_Application_Passwords::get_user_application_password( self::$admin, $uuid );
	$this->assertNotEmpty( $item['expires'] );
}

Possible fix: only touch expires when the request actually has it. This also fixes the PHPStan warning on the same line.

if ( $request->has_param( 'expires' ) ) {
	$prepared->expires = null === $request['expires'] ? null : rest_parse_date( $request['expires'] );
}

PHPStan

Both warnings Peter mentioned still show up locally:

  • user.php:466: Offset 'expires' does not exist on array{...}. expires needs adding to the array shape in the get_user_application_passwords() docblock, and to the matching @type lists in update_application_password() and in the hook docs that pass $item.
  • class-wp-rest-application-passwords-controller.php:587: Strict comparison using !== between null and mixed will always evaluate to true. The has_param() change above removes it.

Design questions from comment:2 on the ticket

These need a call from the component maintainers before this goes further. needs-design is still on the ticket.

  1. Manual input. @TimothyBlynJacobs suggested not adding an expiry field to the profile screen at all, and letting the application set it. This PR adds both a field on create and inline editing.
  2. Authorize flow. authorize-application.php has not changed. An app can't ask for an expiry through the authorize flow, and the approval screen doesn't show one. Comment:2 calls out the one-time setup flow as the main use case, so this looks like the most useful missing piece.
  3. Expired passwords. Expired entries stay in the list forever. Comment:2 suggested hiding them or deleting them some days after they expire.
  4. The app can lift its own expiry. A request authenticated with an expiring app password can PUT {"expires": null} on its own entry before it expires (confirmed with a unit test). This is not a new privilege, because that request can already create a brand new app password. But it means a user-set expiry is not a hard limit, which matters for the "mandatory expiry for AI agents" case in comment:3. One option is to reject expires changes when the request is authenticated with that same password (rest_get_authenticated_app_password()).

One way forward: land the backend first (storage, REST field, auth check, list column, and showing the expiry on the authorize screen), then do the profile UI as a follow-up once design has weighed in. #14069 removes inline editing, so the two PRs disagree on this too.

Minor

  • @since 7.1.0 on column_expires() and on the JS edit handler should be 7.2.0. The JS docblock also has a stray * * @since.
  • create_new_application_password() casts expires with (int). If someone passes a date string such as '2026-12-01 10:00:00', it becomes 2026, and the error says "must be in the future", which is confusing. Rejecting non-numeric values with a clearer message, or saying "Unix timestamp" in the error, would help.
  • On the same row, the new Expires column uses wp_date() (site timezone), but the existing Created column uses date_i18n() with a UTC timestamp. In my test, Created showed "October 17" while the site date was October 18. This is existing behaviour, not something this PR added, but it shows up more now that the two dates sit next to each other.

Checks

  • PHPUnit --filter 'application_password|Application_Password|Tests_Auth': 290 tests pass.
  • PHPCS: no new warnings or errors in the changed PHP files compared to trunk.
  • E2E and QUnit not run.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants