Project

General

Profile

Actions

Feature #43881

open

Strengthen API authentication: API tokens with expiration, scopes, rate limiting and audit logging

Added by Vincent Robert 6 months ago. Updated 5 days ago.

Status:
New
Priority:
Normal
Assignee:
-
Category:
REST API
Target version:
-
Resolution:

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.

I would welcome feedback from the core team on the overall direction before starting implementation, in particular:
  • 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

Related to Redmine - Feature #35001: Disable API authentication with username and password when two-factor authentication is enabled for the userClosedMarius BĂLTEANUActions
Related to Redmine - Feature #43938: Track last usage of API and Atom access keysClosedMarius BĂLTEANUActions
Related to Redmine - Feature #44063: Implement enforcable or automated api key rotationNewActions
Related to Redmine - Patch #44371: Filter key parameter from loggingClosedMarius BĂLTEANUActions
Actions #1

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
Actions #2

Updated by Vincent Robert 5 months ago

  • Related to Feature #43938: Track last usage of API and Atom access keys added
Actions #3

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.

Actions #5

Updated by Marco Descher 3 months ago

Proposed oauth token support to access apikey in https://github.com/kontron/redmine_oauth/issues/34

Actions #6

Updated by Marius BĂLTEANU 3 months ago

  • Related to Feature #44063: Implement enforcable or automated api key rotation added
Actions #7

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.

Actions #8

Updated by Iurii Dremov about 2 months ago

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.

Actions #9

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.

Actions #10

Updated by Iurii Dremov about 2 months ago

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

Actions #11

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_tokens table (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 Doorkeeper hash_token_secrets configuration); the rmpat_-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_lifetime setting (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_user tries the token digest first and falls back to the unchanged legacy API key, across all three transports (header, key parameter, 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 scopes column (space-separated permission names, blank = full access). Enforcement reuses the existing OAuth machinery: scopes are assigned to User#oauth_scope, so the Role#allowed_to? intersection and the admin pseudo-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_enabled setting (Integrations tab). Each API-credential authenticated request writes one JSON line to log/api_audit.log (rotated weekly): timestamp, user, credential id (pat:<id> / api_key / oauth:<id>), method, path, IP, response status. Implemented with prepend_around_action so 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 the key parameter to config.filter_parameters so 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.

Actions #12

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.

Actions #13

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.

  1. The plaintext key in the logs. config.filter_parameters on trunk is [:password, :salt, :twofa_totp_key] and does not cover the key request 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.
  2. 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.
  3. 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) and authorization_code is 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.authenticate only reads OAuth-style credentials (an Authorization: Bearer header or the access_token parameter), while existing clients send X-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 in find_current_user mapping those transports onto Doorkeeper's token lookup.
  • Per-token metadata. oauth_access_tokens has expires_in, revoked_at and scopes, 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.

Actions #14

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 key parameter to config.filter_parameters so the plaintext key no longer appears in production.log.

Actions #15

Updated by Marius BĂLTEANU 5 days ago

  • Related to Patch #44371: Filter key parameter from logging added
Actions

Also available in: Atom PDF