Feature #43881
openStrengthen API authentication: API tokens with expiration, scopes, rate limiting and audit logging
Description
API authentication currently relies on a static API key assigned to each user account, with no expiration date and no second factor requirement.
In my organization, with the recent enforcement of mandatory 2FA for administrator accounts (#35439), there is now a significant security gap between web access and API access: a user's web session is protected by 2FA, but the very same user's API key provides full, unrestricted access without any second factor.
This proposal complements the OAuth2 provider shipped in 6.1 (#24808), which addresses third-party application authentication.
Main identified issues¶
- 2FA bypass — Since #35001, basic authentication with username/password is blocked when 2FA is active. However, API keys still provide full access without any second factor, effectively bypassing the 2FA protection that administrators have required.
- Static keys with no expiration — API keys never expire. Once generated, they remain valid indefinitely until manually reset. A leaked key provides permanent access.
- Excessive privileges — Each API key inherits all permissions of the associated user account, with no ability to restrict access to read-only operations, specific projects, or specific API endpoints.
- No request rate limiting — There is no built-in protection against brute-force attacks on API keys or bulk data extraction.
- Insufficient traceability — API calls are not logged in a structured way (beyond standard application logs).
Proposed improvements to Redmine Core¶
I would like to work on this topic, but these changes should be discussed with Redmine maintainers and integrated into upcoming official releases.
1. Personal Access Tokens with a hybrid management model¶
I propose to replace the current single static API key with Personal Access Tokens, following the hybrid self-service + admin governance model used by GitHub, GitLab, and other major platforms.
Core features of Personal Access Tokens¶
- Multiple named tokens per user
- Mandatory expiration date
- Hashed storage — Token values are stored as SHA256 hashes, consistent with the approach used by Doorkeeper for OAuth2 tokens in #24808. The plaintext is shown only once at creation.
- Last-used tracking
- Management UI — Users manage their tokens in "My account". Administrators have a dedicated panel in Administration to view and manage all tokens across users.
- Backward compatibility — The existing single API key mechanism continues to work during the transition period.
2. Scoped permissions per token¶
- Each token can be restricted to specific permissions (read-only, specific trackers, time logging only, etc.)
- Reuse the scope mechanism already implemented for OAuth2 via Doorkeeper (#24808)
- Optionally restrict a token to specific projects only
- Administrators define which scopes are available globally
3. Rate limiting¶
- Limit the number of API requests per token (and maybe per IP address and/or per endpoint)
- Return standard HTTP 429 (Too Many Requests) responses when limits are exceeded
4. Structured API audit logging¶
- Log all API calls in a dedicated, queryable format (not just standard application logs): token used, endpoint, HTTP method, source IP, timestamp, response status
- Provide an admin UI or API to query and export audit logs
- Enable notifications for anomalous activity: excessive request volume, requests from unknown IPs, repeated authentication failures
5. Granular API endpoint control¶
- Currently, the REST API can only be enabled or disabled globally — it is all or nothing
- Add the ability to disable specific API endpoints or groups of endpoints (e.g. allow issue read but disable user management API)
- This allows administrators to expose only the API surface area that is actually needed
6. CORS configuration?¶
- Allow administrators to define which domains are authorized to access the API from a browser context
- Currently there is no CORS configuration, which means either all origins are allowed or administrators must handle this at the reverse proxy level
How to start?¶
Given the scope of these changes, I propose to start small with a step-by-step approach.
We could start by adding Personal Access Tokens with- self-service creation,
- expiration,
- multiple tokens per user,
- hashed storage,
- admin max-lifetime policy,
- admin token overview panel,
and keep backward compatibility with legacy keys
Other features could be added later.
I am willing to provide patches for these first steps.
- Whether the hybrid self-service + admin governance model seems appropriate
- Whether legacy API key deprecation is desirable, and what transition period would be reasonable
Files
Related issues
Updated by Vincent Robert 6 months ago
- Related to Feature #35001: Disable API authentication with username and password when two-factor authentication is enabled for the user added
Updated by Vincent Robert 5 months ago
- Related to Feature #43938: Track last usage of API and Atom access keys added
Updated by Dennis Buehring 4 months ago
We have a problem with more and more users using their api keys to query and automate things in redmine, thanks chatgpt ;)
i would like to be able to allow/enable token generation only for specific users, same as with other roles i guess.
Updated by Marco Descher 3 months ago
Related request: https://www.redmine.org/issues/44063
Updated by Marco Descher 3 months ago
Proposed oauth token support to access apikey in https://github.com/kontron/redmine_oauth/issues/34
Updated by Marius BĂLTEANU 3 months ago
- Related to Feature #44063: Implement enforcable or automated api key rotation added
Updated by Iurii Dremov about 2 months ago
I'm working on pillar 3 of this proposal (rate limiting) and have a working implementation built against 6.1.2 that I'm now porting to trunk: a fixed-window per-user limit (per-IP for unauthenticated callers) for REST API requests, configurable via a new setting (disabled by default), responding 429 with Retry-After.
I'll attach the patch with tests once the trunk port is ready — feedback on the approach is welcome.
Updated by Iurii Dremov about 2 months ago
- File 0001-Add-rate-limiting-for-the-REST-API-43881.patch 0001-Add-rate-limiting-for-the-REST-API-43881.patch added
Attached is a patch against current trunk implementing the rate limiting part (pillar 3) of this proposal.
What it does:
- Fixed-window (60 s) request counting per authenticated user, or per source IP for unauthenticated requests; covers the API key, HTTP Basic and OAuth2 authentication paths.
- New integer setting rest_api_rate_limit (requests per minute, 0 = disabled; default 0, so existing installations are unaffected), shown on the API settings tab next to rest_api_enabled.
- Over the limit the API responds 429 Too Many Requests with Retry-After and X-RateLimit-Limit headers and an errors body in JSON/XML (reusing common/error_messages.api.rsb); each rejection is logged via Rails.logger.warn
- Counters live in a cache store (Rails.cache by default, injectable — the tests swap in a memory store since the test environment runs :null_store). With a store that cannot count, the limiter fails open.
- Web UI requests are never throttled (the filter is guarded by api_request?)
Known limitations, by design: fixed-window boundary bursts (up to 2x the limit across a window edge); with the default :file_store/:memory_store counters are per host/process — a shared limit across nodes needs memcached/redis;
remote_ip trust depends on the reverse proxy setup.
Tested with Ruby 3.4/SQLite: the new unit + integration tests pass (14 runs), and the whole test/integration/api_test suite stays green (382 runs). RuboCop is clean on the touched files.
Feedback welcome — happy to adjust naming, defaults or scope (per-endpoint limits, X-RateLimit-Remaining, …) based on maintainer guidance.
Updated by Marius BĂLTEANU about 2 months ago
Thanks Iurii Dremov for working on this!
Is there any reason why you didn't use the existing rate_limit API provided since Rails 7.2? https://api.rubyonrails.org/classes/ActionController/RateLimiting/ClassMethods.html The feature was improved in Rails 8.0 to support multiple rate limits.
Updated by Iurii Dremov about 2 months ago
- File 0001-Add-rate-limiting-for-the-REST-API-43881.patch 0001-Add-rate-limiting-for-the-REST-API-43881.patch added
No good reason. Thanks for the pointer.
The only thing in the way was the setting: to: and within: are captured when the class body runs, while rest_api_rate_limit is meant to be editable in the admin UI without a restart. So I kept the setting and called rate_limiting from a before_action placed after user_setup, so that the bucket can be the current user and fall back to the IP:
def check_api_rate_limit
limit = Setting.rest_api_rate_limit.to_i
return unless api_request? && limit > 0
rate_limiting :to => limit, :within => 1.minute, :scope => 'api', :name => nil,
:by => lambda {User.current.logged? ? "user/#{User.current.id}" : "ip/#{request.remote_ip}"},
:with => lambda {render_api_rate_limit_exceeded(limit)},
:store => Rails.cache
end
This drops my own counter and its unit test, and the rate_limit.action_controller notification is a better hook for the audit logging pillar of this issue than the logger.warn I had. The downside is the dependency on a private method.
If you prefer to stay on the public macro, the limit has to move to configuration.yml and changing it then needs a restart. I can send that version instead.
Updated patch attached
Updated by Bogdan Egikov 9 days ago
I have implemented pillar 1 (personal access tokens) together with pillars 2 (scoped permissions) and 4 (structured audit logging) as a single patch against current trunk. Attached: personal-access-tokens-43881.patch.
Personal access tokens
- New
personal_access_tokenstable (one migration) and model: multiple named tokens per user, each with a mandatory expiration date. Only the SHA256 digest of the value is stored (same strategy as the existing Doorkeeperhash_token_secretsconfiguration); thermpat_-prefixed value is displayed exactly once, at creation. - Self-service UI under My account (list / create / revoke), protected by sudo mode; throttled last-used tracking; a new
personal_access_token_max_lifetimesetting (Administration -> Settings -> Integrations tab,format: int, 0 = no limit, security-notified). The new-token form clamps its default expiration to this policy. - Fully backward compatible:
find_current_usertries the token digest first and falls back to the unchanged legacy API key, across all three transports (header,keyparameter, HTTP Basic username). Existing keys and integrations are not affected - same coexistence approach as the OAuth provider introduction in 6.1.0. - A tested
PersonalAccessToken.import_legacy_api_tokens!is included but deliberately not invoked: legacy plaintext keys can be converted to hashed tokens server-side without invalidating them (lookup is by digest), so a future release can retire plaintext keys with a data migration in the style of SaltUserPasswords (20110223180953).
Scoped permissions
- Optional
scopescolumn (space-separated permission names, blank = full access). Enforcement reuses the existing OAuth machinery: scopes are assigned toUser#oauth_scope, so theRole#allowed_to?intersection and theadminpseudo-scope apply unchanged, and public permissions are force-included like OAuth applications. - Note: this inherits the open defect #44271 (issue attribute editing goes through
Issue#user_tracker_permission?, bypassing the scope intersection). The patch pins that behavior with a clearly-labelled test that will start failing once #44271 is fixed, so both mechanisms pick up the fix together.
Structured audit logging
- Opt-in
api_audit_logging_enabledsetting (Integrations tab). Each API-credential authenticated request writes one JSON line tolog/api_audit.log(rotated weekly): timestamp, user, credential id (pat:<id>/api_key/oauth:<id>), method, path, IP, response status. Implemented withprepend_around_actionso requests denied by a halted filter chain (401/403) are audited as well, a true 500 is recorded when an exception is in flight, and logging failures degrade silently instead of breaking API responses. The path is logged without the query string, and the patch also adds thekeyparameter toconfig.filter_parametersso the plaintext key no longer appears in production.log.
Not covered here
- Rate limiting - left to the ongoing discussion in this ticket.
- CORS (new dependency), granular endpoint control, and an Administration panel over all users' tokens - kept out to keep this reviewable; happy to follow up on any of them.
Verification
Checked on a clean trunk checkout at f92786ec6 (Rails 8.1.3.1, Ruby 3.3, SQLite): git apply --check passes, and the full test suite (bin/rails test) gives
- before the patch: 5734 runs, 29842 assertions, 29 failures, 3 errors, 102 skips
- after the patch: 5795 runs, 30010 assertions, 29 failures, 3 errors, 102 skips
The failure lists before and after are identical - all of them are environment-dependent on my machine (27 SCM tests needing local test repositories from rake test:scm:setup, 4 LDAP tests needing a test LDAP server, 1 changeset test) and are unrelated to the patch, which adds 61 passing tests. RuboCop reports no offenses on the changed files.
Feedback welcome - I will gladly iterate on the patch based on review.
Updated by Holger Just 9 days ago
Thanks for your work on this!
Most of the functionality of your new personal access tokens seems to mirror what we already have with the oauth applications (e.g. restricted scopes or token live times) as implemented in #24808. As such, I'm not sure if it's actual a good idea to duplicate most of this again. Instead, I would prefer an approach where we adapt / extend the existing oauth applications so that we could issue long-lived access tokens from there.
Also, this is quite a large patch. Could you try to separate different concerns into separate patches, e.g. to add the audit log functionality in a separate patch?
In general, each of the features proposed here are rather large and complex on its own (rate limiting, changed API tokens, audit logs, ...). I think we should try to separate these features into separate issues (and thus separate patches) to allow us to properly review these changes on their own. As these change significantly affect the core authentication and authorization logic in Redmine, I think we have to be quite careful here.
Updated by Bogdan Egikov 9 days ago
Thanks for the quick and detailed review.
On splitting: agreed, and I would rather do it in order of increasing risk, so each piece can be reviewed on its own.
- The plaintext key in the logs.
config.filter_parameterson trunk is[:password, :salt, :twofa_totp_key]and does not cover thekeyrequest parameter, so an API key passed as?key=...is written to production.log in the clear. Two lines and a test, independent of everything else here - I will open it as a separate defect issue. - Audit logging. Self-contained and opt-in; it touches authentication only through a marker set during credential lookup, so it can follow whatever token shape we settle on. Its own feature issue and patch.
- The token change proper - the part worth agreeing on first, below. Should scoped permissions be a fourth issue, or stay with the token patch since they live on the same model?
On reusing the OAuth applications instead of a new model: the #24808 machinery already gives hashed storage, expiry, revocation and scopes, and going that way would keep a single hashed-token store and a single scope-enforcement path, which is a real win. Three things stopped me, and I would value your read on them:
- Self-service. Application registration is admin-only (
admin_authenticator) andauthorization_codeis the only configured grant. A user who needs a token for a cron job would first need an admin to register an application and then walk a redirect flow, so issuing long-lived tokens from My account means adding a non-interactive path to Doorkeeper in any case. - Transports.
Doorkeeper.authenticateonly reads OAuth-style credentials (anAuthorization: Bearerheader or theaccess_tokenparameter), while existing clients sendX-Redmine-API-Key,?key=or the key as the HTTP Basic username. Long-lived OAuth tokens would either not work with the current ecosystem, or need a shim infind_current_usermapping those transports onto Doorkeeper's token lookup. - Per-token metadata.
oauth_access_tokenshasexpires_in,revoked_atandscopes, but not a user-visible name, last-used tracking or an admin max-lifetime policy, so those become extra columns there or a side table either way.
If extending the OAuth applications is the direction core prefers - a built-in per-user application, or a grant that issues long-lived tokens from My account - I am happy to rework it that way. I would rather build what core wants than argue for my own version, so I will hold off on re-cutting the patches until you tell me which shape you would like to review.
Updated by Marius BĂLTEANU 5 days ago
Holger Just, Bogdan Egikov, I've extracted this to #44371 in order to include it in the next releases.
Bogdan Egikov wrote in #note-11:
the patch also adds the
keyparameter toconfig.filter_parametersso the plaintext key no longer appears in production.log.
Updated by Marius BĂLTEANU 5 days ago
- Related to Patch #44371: Filter key parameter from logging added