Skip to content

Allow authentication by JWT Bearer token#7826

Open
melton-jason wants to merge 29 commits intomainfrom
issue-5163
Open

Allow authentication by JWT Bearer token#7826
melton-jason wants to merge 29 commits intomainfrom
issue-5163

Conversation

@melton-jason
Copy link
Copy Markdown
Contributor

@melton-jason melton-jason commented Mar 18, 2026

Fixes #5163

This PR allows a new method of authenticating with the API. Specifically, this PR allows authentication via JWT Bearer tokens.

Previously, the required workflow to authenticate via the API required:

  • Sending a GET request to an endpoint that doesn't require clients to be logged in (e.g., /context/login/).
  • Extracting the CSRF Token from the response's cookies
    • The token must be passed as a X-CSRFToken header with the each unsafe request made to the backend
  • Sending a PUT request to /context/login/ with the user's name, password, and collection id

For an example, the prior workflow can be modeled by something like the following Python pseudo code (inspired by the requests library):

initial_resp = session.get("/context/login/")
# collections is the mapping of collection name to collection id
collections = json.loads(initial_resp.content)["collections"]
# We need to store the CSRF Token for later
# It then must be passed along with every unsafe request (PUT, POST, DELETE)
# in the X-CSRFToken header
csrf_token = initial_resp.cookies["csrftoken"]

login_resp = session.put("/context/login/", json={"username": "myuser", "password": "mypassword",
                         "collection": my_collection_id}, headers={"X-CSRFToken": csrf_token})

if login_resp.status_code != 204:
    # invalid credentials
    return

# now the user is logged in
# note they still have to pass the CSRF Token if they want to make an unsafe request

# For example, to create a new John Doe Agent: 
new_agent = session.post("/api/specify/agent/",
             json={"agenttype": 1, "lastname": "Doe", "firstname": "John"},
             headers={"X-CSRFToken": csrf_token})

With the new approach, users of the API only require:

  • The ID of the Collection they wish to perform actions in (this can still be retrieved from the prior /context/login/ GET endpoint)
  • Sending a POST request to /accounts/token/ with their username, password, and desired collection id to retrieve an access token
  • In future requests, send the access token within an Authorization header

Overview

Acquiring an Access Token

Access tokens can be acquired by sending a POST request to /accounts/token/ and passing the username, password, collectionid, and optionally expires.

If the request is successful, the access token is retrievable by the access_token key in the response's JSON output.

By default, access tokens last 1800 seconds (30 minutes), but their lifespan can be configured (see below Setting a token's lifespan).

Example with curl:

> curl -d "username=myuser&password=mypass&collectionid=4" http://localhost/accounts/token/
{"access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOjEsInVzZXJuYW1lIjoic3BmaXNoYWRtaW4iLCJjb2xsZWN0aW9uIjo0LCJqdGkiOiI2NWMzMmYwNy1hYTMzLTQxN2MtYjI2Ny02MDQwOGQyOTQ0ZjYiLCJpYXQiOjE3NzM5NDUxODIsImV4cCI6MTc3Mzk0Njk4Mn0.s3FTc9EeObiSmm9FLywlpdHkXMKiAob1QuVkW8pp3_o", "expires_in": 1800}

In the above case, the resulting access token is eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOjEsInVzZXJuYW1lIjoic3BmaXNoYWRtaW4iLCJjb2xsZWN0aW9uIjo0LCJqdGkiOiI2NWMzMmYwNy1hYTMzLTQxN2MtYjI2Ny02MDQwOGQyOTQ0ZjYiLCJpYXQiOjE3NzM5NDUxODIsImV4cCI6MTc3Mzk0Njk4Mn0.s3FTc9EeObiSmm9FLywlpdHkXMKiAob1QuVkW8pp3_o.

Example with Python requests

import requests
session = requests.Session()

resp = session.post('http://localhost/accounts/token/', data={
    "username": "myuser",
    "password": "mypass",
    "collectionid": 4
})

response = json.loads(resp.content)

Setting a token's lifespan

By default, access tokens last 1800 seconds (30 minutes).
An access token's lifespan can be set by passing in an expires attribute when requesting the token. The backend expects expires to be in seconds.

Once an access token expires, it will not be usable and a new access token needs to be generated.
An access token can be made invalid regardless of its expiration time by revoking it (see Revoking an Access Token).

Example of generating an access token that's live for 5 minutes (300 seconds) with curl:

curl -d "username=myuser&password=mypass&collectionid=4&expires=300" http://localhost/accounts/token/

Example of generating an access token that's live for 5 minutes (300 seconds) with Python requests:

import requests
session = requests.Session()

session.post('http://localhost/accounts/token/', data={
    "username": "myuser",
    "password": "mypass",
    "collectionid": 4,
    "expires": 300
})

Using an Access Token

Once an access token is generated, it can be used by passing it in subsequent requests by the Authorization header with the Bearer scheme.
In other words, the general form of the Authorization header should look like Authorizarion: Bearer <my_token>, where <my_token> is replaced with the access token.

Example of fetching the institutional hierarchy (Institution, Division, Discipline, Collection) for each Collection using curl:

> curl -H "Authorization: Bearer my_token" "http://localhost/api/specify_rows/institution/?fields=name,divisions__name,divisions__disciplines__name,divisions__disciplines__collections__collectionname"
[["University of Kansas Biodiversity Institute", "Ichthyology", "Ichthyology", "KU Fish Observation Collection"], ["University of Kansas Biodiversity Institute", "Ichthyology", "Ichthyology", "KU Fish Teaching Collection"], ["University of Kansas Biodiversity Institute", "Ichthyology", "Ichthyology", "KU Fish Tissue Collection"], ["University of Kansas Biodiversity Institute", "Ichthyology", "Ichthyology", "KU Fish Voucher Collection"]]

Example of creating a new agent using Python requests:

import requests
session = requests.Session()

resp = session.post("/accounts/token/", data={
    "username": "myuser",
    "password": "mypassword",
    "collectionid": my_collection_id
})

token = json.loads(resp.content)["access_token"]

session.post("/api/specify/agent/", json={"agenttype": 1, "lastname": "Doe", "firstname": "John"}, headers={"Authorization": f"Bearer {token}"})

If the token is invalid, expired, or revoked then Specify will return a 401 Unauthorized response with the WWW-Authenticate headers indicating an invalid token:

> curl -I -H "Authorization: Bearer my_invalid_token" http://localhost/api/specify/collectionobject/
HTTP/1.1 401 Unauthorized
Server: nginx/1.29.6
Date: Thu, 19 Mar 2026 19:13:31 GMT
Content-Type: text/html; charset=utf-8
Connection: keep-alive
WWW-Authenticate: error="invalid_token", error_description="The access token is expired, revoked, or invalid"
Vary: Accept-Language
Content-Language: en-us

Revoking an Access Token

An access token can be made invalid by revoking it. To revoke a token, a POST request can be sent to /accounts/token/revoke/ where the request body includes the token to be revoked under an access_token key.
The client must be authenticated (whether via the previous session authentication or by access token) to make the request.

The same token that is being used to authorize the request to revoke an access token can be revoked. That is, an token can revoke itself.

Below is a snippet of Python that shows how to revoke an access token:

session.post("/accounts/token/revoke/", headers={"Authorization": f"Bearer {my_existing_token}"}, data={"access_token": my_token_to_revoke})

If the token to be revoked is invalid or expired, a 400 Bad Request is returned by the server.

OpenAPI

If you need a reminder/refresher about the token endpoints, they are documented and available to try out at the instance's Operations API page (accessible via User Tools)

Screenshot 2026-03-19 at 3 13 48 PM

Checklist

  • Self-review the PR after opening it to make sure the changes look good and
    self-explanatory (or properly documented)
  • Add relevant issue to release milestone
  • Add pr to documentation list
  • Add automated tests

TODO

  • (In this PR or in the future) Support passing a refresh token along with an authorization token when providing the access token to the client. Decrease/limit the lifespan of access tokens and instead allow refresh tokens to assign new access tokens to an "already authorized" client.

Testing instructions

In your testing, you can use any client that supports sending HTTP/HTTPS requests: curl, Postman, any supported programming language, etc.

  • Send a POST request to /accounts/token/ containing the username for the user you want to login as, the password, and the desired collection

  • Ensure the access token is returned, and record the access token for use in future requests

  • Send a "safe" request (one with a GET method) that requires permissions (such as fetching a specific record or a collection of records) and set the Authorization header of the request to Bearer <my_token>, replacing <my_token> with your access token

  • Ensure the request can be fulfilled and the correct data is returned

  • Send an "unsafe" request (one with a POST, PUT, DELETE method, such as creating a new record, updating/delete a record, etc.) and set the Authorization header of the request to Bearer <my_token>, replacing <my_token> with your access token

  • Ensure the request can be fulfilled and the requested operation successfully performed

  • Generate an access token with a short time to live (lifespan)-- such as 30 seconds, 1 minute, 3 minutes, etc.

  • Wait for the token to expire and the time to live to elapse

  • Send a privileged request using the access token and ensure the request fails and the response has a 401 status code

  • Revoke an active access token that is still going to be live by the time the next step is performed using the /accounts/token/revoke/

  • Send a privileged request using the revoked access token and ensure the request fails and the response has a 401 status code

  • Attempt to generate an access token to a collection that exists but that the user does not have access to

  • Ensure server returns with a 403 Forbidden status response and does not generate the access token

@melton-jason melton-jason marked this pull request as ready for review March 19, 2026 18:28
@melton-jason melton-jason added this to the 7.12.1 milestone Mar 19, 2026
Copy link
Copy Markdown
Member

@acwhite211 acwhite211 left a comment

Choose a reason for hiding this comment

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

Nice improvement to our API authentication 👍

Comment thread specifyweb/backend/accounts/views.py
Comment thread Dockerfile Outdated
@github-project-automation github-project-automation Bot moved this from 📋Back Log to Dev Attention Needed in General Tester Board Mar 20, 2026
@melton-jason melton-jason requested a review from acwhite211 April 1, 2026 15:50
@melton-jason melton-jason requested a review from a team April 1, 2026 15:50
@grantfitzsimmons grantfitzsimmons self-requested a review April 6, 2026 14:07
Copy link
Copy Markdown
Member

@grantfitzsimmons grantfitzsimmons left a comment

Choose a reason for hiding this comment

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

In the issue this is resolving, there is a specific request:

We should add support for an API key/token (or similar approach) that can be generated within the security & accounts system and reused.

This was intended to communicate the need for an option in the user interface for generating this. Does it add too much to the scope to integrate this?

Seems we can add a button in the UI for a user in a particular collection to generate this one-time token and save it

Copy link
Copy Markdown
Member

@grantfitzsimmons grantfitzsimmons left a comment

Choose a reason for hiding this comment

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

Testing instructions

  • Send a POST request to /accounts/token/ containing the username for the user you want to login as, the password, and the desired collection
  • Ensure the access token is returned, and record the access token for use in future requests
❯ curl -sS -X POST http://localhost/accounts/token/ --data-urlencode "username=spadmin" --data-urlencode "password=test#password" --data-urlencode "collectionid=4"
{"access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOjEsInVzZXJuYW1lIjoic3BlbnRvYWRtaW4iLCJjb4xsZWN0aW9uIjo0LCJqdGkiOiJhYjcyNzY0MC02MzE2LTQzODItOTAzYy0wMjRmMjUyMGMwOMMiLCJpYXQiOjE3NzU2NzkyMDUsImV4cCI6MTc3NTY4MTAwNX0.wzthbaZzbPb5fkbPhVQ8Qc5R9en2_Ks-55FgnjWbtmI", "expires_in": 1800}%

I had to adjust the structure since I had special characters in my password.

  • Send a "safe" request (one with a GET method) that requires permissions (such as fetching a specific record or a collection of records) and set the Authorization header of the request to Bearer <my_token>, replacing <my_token> with your access token
❯ curl -H "Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOjEsInVzZXJuYW1lIjoic3BlbnRvYWRtaW4iLCJjb2xsZWN0aW9uIjo0LCJqdGkiOiI5ZDkzYjBlMC0wMmU1LTQyMTQtYjE4Mi01NzY4M2Q4MjRkNjYiLCJpYXQiOjE3NzU2NzkwMjAsImV4cCI6MTc3NTY4MDgyMH0.Uub2cBRwap0yCzpRzUooENBsqsx0PSBcV7vgRGjg4nQ" "http://localhost/api/specify_rows/institution/?fields=name,divisions__name,divisions__disciplines__name,divisions__disciplines__collections__collectionname"
[["University of Kansas Biodiversity Institute", "Entomology", "Botany", "KUEntoPlant"], ["University of Kansas Biodiversity Institute", "Entomology", "Botany (2)", "Collection"], ["University of Kansas Biodiversity Institute", "Entomology", "Botany (2)", "Collection2"], ["University of Kansas Biodiversity Institute", "Entomology", "Botany (2)", "Collection3"], ["University of Kansas Biodiversity Institute", "Entomology", "Entomology", "KUEntoPinned"], ["University of Kansas Biodiversity Institute", "Entomology", "Herpetology", null], ["University of Kansas Biodiversity Institute", "Entomology", "Invertebrate Paleontology", "KUEntoFossil"]]%
  • Ensure the request can be fulfilled and the correct data is returned
  • Send an "unsafe" request (one with a POST, PUT, DELETE method, such as creating a new record, updating/delete a record, etc.) and set the Authorization header of the request to Bearer <my_token>, replacing <my_token> with your access token
❯ curl -X POST \
     -H "Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOjEsInVzZXJuYW1lIjoic3BlbnRvYWRtaW4iLCJjb2xsZWN0aW9uIjo0LCJqdGkiOiI5ZDkzYjBlMC0wMmU1LTQyMTQtYjE4Mi01NzY4M2Q4MjRkNjYiLCJpYXQiOjE3NzU2NzkwMjAsImV4cCI6MTc3NTY4MDgyMH0.Uub2cBRwap0yCzpRzUooENBsqsx0PSBcV7vgRGjg4nQ" \
     -H "Content-Type: application/json" \
     -d '{
           "agenttype": 1,
           "lastname": "Fitzsimmons",
           "firstname": "Grant"
         }' \
     http://localhost/api/specify/agent/

{"id": 10482, "abbreviation": null, "agenttype": 1, "date1": null, "date1precision": null, "date2": null, "date2precision": null, "dateofbirth": null, "dateofbirthprecision": null, "dateofdeath": null, "dateofdeathprecision": null, "datetype": null, "email": null, "firstname": "Grant", "guid": "6c4dde2e-5ce4-4591-b4d5-3c537e6adc68", "initials": null, "integer1": null, "integer2": null, "interests": null, "jobtitle": null, "lastname": "Fitzsimmons", "middleinitial": null, "remarks": null, "suffix": null, "text1": null, "text2": null, "text3": null, "text4": null, "text5": null, "timestampcreated": "2026-04-08T15:18:22.812123", "timestampmodified": "2026-04-08T15:18:22.812132", "title": null, "url": null, "verbatimdate1": null, "verbatimdate2": null, "version": 0, "collcontentcontact": null, "colltechcontact": null, "createdbyagent": "/api/specify/agent/3/", "division": null, "instcontentcontact": null, "insttechcontact": null, "modifiedbyagent": null, "organization": null, "specifyuser": null, "addresses": [], "orgmembers": "/api/specify/agent/?organization=10482", "agentattachments": [], "agentgeographies": [], "identifiers": [], "agentspecialties": [], "variants": [], "collectors": "/api/specify/collector/?agent=10482", "components": "/api/specify/component/?identifiedby=10482", "groups": [], "members": "/api/specify/groupperson/?member=10482", "resource_uri": "/api/specify/agent/10482/"}%                                                                           ~ ❯
  • Ensure the request can be fulfilled and the requested operation successfully performed

  • Generate an access token with a short time to live (lifespan)-- such as 30 seconds, 1 minute, 3 minutes, etc.

curl -X POST \
     --data-urlencode "username=spadmin" \
     --data-urlencode "password=test#password" \
     --data-urlencode "collectionid=4" \
     --data-urlencode "expires=10" \
     http://localhost/accounts/token/
  • Wait for the token to expire and the time to live to elapse
  • Send a privileged request using the access token and ensure the request fails and the response has a 401 status code

After 1 minute:

curl -X POST \
     -H "Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOjEsInVzZXJuYW1lIjoic3BlbnRvYWRtaW4iLCJjb2xsZWN0aW9uIjo0LCJqdGkiOiJmMTdlMDkzMy04NDc2LTRkOTUtOTY0Ni1kNTQwYmMyZjY5ODYiLCJpYXQiOjE3NzU2Nzk2MDIsImV4cCI6MTc3NTY3OTYxMn0.v-I48oKuFWbcK7FtyK9sECA_je0dvR1wK-9FQBOlQAU" \
     -H "Content-Type: application/json" \
     -d '{
           "agenttype": 1,
           "lastname": "Melton",
           "firstname": "Jason"
         }' \
     http://localhost/api/specify/agent/

Invalid access token%
  • Revoke an active access token that is still going to be live by the time the next step is performed using the /accounts/token/revoke/
❯ curl -X POST \
     -H "Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOjEsInVzZXJuYW1lIjoic3BlbnRvYWRtaW4iLCJjb2xsZWN0aW9uIjo0LCJqdGkiOiI5ZDkzYjBlMC0wMmU1LTQyMTQtYjE4Mi01NzY4M2Q4MjRkNjYiLCJpYXQiOjE3NzU2NzkwMjAsImV4cCI6MTc3NTY4MDgyMH0.Uub2cBRwap0yCzpRzUooENBsqsx0PSBcV7vgRGjg4nQ" \
     --data-urlencode "access_token=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOjEsInVzZXJuYW1lIjoic3BlbnRvYWRtaW4iLCJjb2xsZWN0aW9uIjo0LCJqdGkiOiJiY2UxNTc5MS1mMWIwLTQ3MjUtYjQyOC00YTlkMmI1MzQzYzciLCJpYXQiOjE3NzU2Nzk3MDksImV4cCI6MTc3NTY4MTUwOX0.XXHAGZLYb6QUY4wlDBItasXKZHgr1v5akoPAsJhdRIo" \
     http://localhost/accounts/token/revoke/
  • Send a privileged request using the revoked access token and ensure the request fails and the response has a 401 status code

  • Attempt to generate an access token to a collection that exists but that the user does not have access to

  • Ensure server returns with a 403 Forbidden status response and does not generate the access token

This user doesn't have access to log into the KUEntoPinned collection, yet I could get a token and create a record:

Image Image
❯ curl -sS -X POST http://localhost/accounts/token/ --data-urlencode "username=jthomas" --data-urlencode "password=testuser" --data-urlencode "collectionid=4"
{"access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOjIsInVzZXJuYW1lIjoianRob21hcyIsImNvbGxlY3Rpb24iOjQsImp0aSI6IjU3MWI0M2U1LTVlZDQtNDdhMS1iYTlmLWJkOGRhYzE1ZTc1NiIsImlhdCI6MTc3NTY3OTgyOCwiZXhwIjoxNzc1NjgxNjI4fQ.8dgWMzVkAss_J10J1f7P_AONMrEj3_nGaKpmnXBJ4QA", "expires_in": 1800}%                                                          ❯ curl -X POST \
     -H "Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOjEsInVzZXJuYW1lIjoic3BlbnRvYWRtaW4iLCJjb2xsZWN0aW9uIjo0LCJqdGkiOiI5ZDkzYjBlMC0wMmU1LTQyMTQtYjE4Mi01NzY4M2Q4MjRkNjYiLCJpYXQiOjE3NzU2NzkwMjAsImV4cCI6MTc3NTY4MDgyMH0.Uub2cBRwap0yCzpRzUooENBsqsx0PSBcV7vgRGjg4nQ" \
     --data-urlencode "access_token=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOjEsInVzZXJuYW1lIjoic3BlbnRvYWRtaW4iLCJjb2xsZWN0aW9uIjo0LCJqdGkiOiJiY2UxNTc5MS1mMWIwLTQ3MjUtYjQyOC00YTlkMmI1MzQzYzciLCJpYXQiOjE3NzU2Nzk3MDksImV4cCI6MTc3NTY4MTUwOX0.XXHAGZLYb6QUY4wlDBItasXKZHgr1v5akoPAsJhdRIo" \
     http://localhost/accounts/token/revoke/
❯ curl -X POST \
     -H "Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOjIsInVzZXJuYW1lIjoianRob21hcyIsImNvbGxlY3Rpb24iOjQsImp0aSI6IjU3MWI0M2U1LTVlZDQtNDdhMS1iYTlmLWJkOGRhYzE1ZTc1NiIsImlhdCI6MTc3NTY3OTgyOCwiZXhwIjoxNzc1NjgxNjI4fQ.8dgWMzVkAss_J10J1f7P_AONMrEj3_nGaKpmnXBJ4QA" \
     -H "Content-Type: application/json" \
     -d '{
           "agenttype": 1,
           "lastname": "Melton",
           "firstname": "Jason"
         }' \
     http://localhost/api/specify/agent/

{"id": 10483, "abbreviation": null, "agenttype": 1, "date1": null, "date1precision": null, "date2": null, "date2precision": null, "dateofbirth": null, "dateofbirthprecision": null, "dateofdeath": null, "dateofdeathprecision": null, "datetype": null, "email": null, "firstname": "Jason", "guid": "3a8a73a8-b2bf-41cd-bb6b-49f88e86c676", "initials": null, "integer1": null, "integer2": null, "interests": null, "jobtitle": null, "lastname": "Melton", "middleinitial": null, "remarks": null, "suffix": null, "text1": null, "text2": null, "text3": null, "text4": null, "text5": null, "timestampcreated": "2026-04-08T15:24:19.966392", "timestampmodified": "2026-04-08T15:24:19.966593", "title": null, "url": null, "verbatimdate1": null, "verbatimdate2": null, "version": 0, "collcontentcontact": null, "colltechcontact": null, "createdbyagent": "/api/specify/agent/6049/", "division": null, "instcontentcontact": null, "insttechcontact": null, "modifiedbyagent": null, "organization": null, "specifyuser": null, "addresses": [], "orgmembers": "/api/specify/agent/?organization=10483", "agentattachments": [], "agentgeographies": [], "identifiers": [], "agentspecialties": [], "variants": [], "collectors": "/api/specify/collector/?agent=10483", "components": "/api/specify/component/?identifiedby=10483", "groups": [], "members": "/api/specify/groupperson/?member=10483", "resource_uri": "/api/specify/agent/10483/"}%                                                                                                                                                  ~ ❯   

@grantfitzsimmons grantfitzsimmons modified the milestones: 7.12.1, 7.12.2 Apr 20, 2026
@melton-jason
Copy link
Copy Markdown
Contributor Author

melton-jason commented Apr 22, 2026

  • Send a privileged request using the revoked access token and ensure the request fails and the response has a 401 status code
  • Attempt to generate an access token to a collection that exists but that the user does not have access to
  • Ensure server returns with a 403 Forbidden status response and does not generate the access token

This user doesn't have access to log into the KUEntoPinned collection, yet I could get a token and create a record:

Image Image

❯ curl -sS -X POST http://localhost/accounts/token/ --data-urlencode "username=jthomas" --data-urlencode "password=testuser" --data-urlencode "collectionid=4"
{"access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOjIsInVzZXJuYW1lIjoianRob21hcyIsImNvbGxlY3Rpb24iOjQsImp0aSI6IjU3MWI0M2U1LTVlZDQtNDdhMS1iYTlmLWJkOGRhYzE1ZTc1NiIsImlhdCI6MTc3NTY3OTgyOCwiZXhwIjoxNzc1NjgxNjI4fQ.8dgWMzVkAss_J10J1f7P_AONMrEj3_nGaKpmnXBJ4QA", "expires_in": 1800}%                                                          ❯ curl -X POST \
     -H "Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOjEsInVzZXJuYW1lIjoic3BlbnRvYWRtaW4iLCJjb2xsZWN0aW9uIjo0LCJqdGkiOiI5ZDkzYjBlMC0wMmU1LTQyMTQtYjE4Mi01NzY4M2Q4MjRkNjYiLCJpYXQiOjE3NzU2NzkwMjAsImV4cCI6MTc3NTY4MDgyMH0.Uub2cBRwap0yCzpRzUooENBsqsx0PSBcV7vgRGjg4nQ" \
     --data-urlencode "access_token=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOjEsInVzZXJuYW1lIjoic3BlbnRvYWRtaW4iLCJjb2xsZWN0aW9uIjo0LCJqdGkiOiJiY2UxNTc5MS1mMWIwLTQ3MjUtYjQyOC00YTlkMmI1MzQzYzciLCJpYXQiOjE3NzU2Nzk3MDksImV4cCI6MTc3NTY4MTUwOX0.XXHAGZLYb6QUY4wlDBItasXKZHgr1v5akoPAsJhdRIo" \
     http://localhost/accounts/token/revoke/
❯ curl -X POST \
     -H "Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOjIsInVzZXJuYW1lIjoianRob21hcyIsImNvbGxlY3Rpb24iOjQsImp0aSI6IjU3MWI0M2U1LTVlZDQtNDdhMS1iYTlmLWJkOGRhYzE1ZTc1NiIsImlhdCI6MTc3NTY3OTgyOCwiZXhwIjoxNzc1NjgxNjI4fQ.8dgWMzVkAss_J10J1f7P_AONMrEj3_nGaKpmnXBJ4QA" \
     -H "Content-Type: application/json" \
     -d '{
           "agenttype": 1,
           "lastname": "Melton",
           "firstname": "Jason"
         }' \
     http://localhost/api/specify/agent/

{"id": 10483, "abbreviation": null, "agenttype": 1, "date1": null, "date1precision": null, "date2": null, "date2precision": null, "dateofbirth": null, "dateofbirthprecision": null, "dateofdeath": null, "dateofdeathprecision": null, "datetype": null, "email": null, "firstname": "Jason", "guid": "3a8a73a8-b2bf-41cd-bb6b-49f88e86c676", "initials": null, "integer1": null, "integer2": null, "interests": null, "jobtitle": null, "lastname": "Melton", "middleinitial": null, "remarks": null, "suffix": null, "text1": null, "text2": null, "text3": null, "text4": null, "text5": null, "timestampcreated": "2026-04-08T15:24:19.966392", "timestampmodified": "2026-04-08T15:24:19.966593", "title": null, "url": null, "verbatimdate1": null, "verbatimdate2": null, "version": 0, "collcontentcontact": null, "colltechcontact": null, "createdbyagent": "/api/specify/agent/6049/", "division": null, "instcontentcontact": null, "insttechcontact": null, "modifiedbyagent": null, "organization": null, "specifyuser": null, "addresses": [], "orgmembers": "/api/specify/agent/?organization=10483", "agentattachments": [], "agentgeographies": [], "identifiers": [], "agentspecialties": [], "variants": [], "collectors": "/api/specify/collector/?agent=10483", "components": "/api/specify/component/?identifiedby=10483", "groups": [], "members": "/api/specify/groupperson/?member=10483", "resource_uri": "/api/specify/agent/10483/"}%                                                                                                                                                  ~ ❯   

@grantfitzsimmons
Thank you for the review!

I actually wasn't able to directly recreate the Issue you've described: I always correctly receive a 403 Forbidden response when both generating a token for a collection a user does not have access to, and when using a token that's scoped to a collection the user doesn't have access to (such as when collection access is revoked while a token is still live, or when the token is forged because the SECRET_KEY was leaked).

However, I do think I understand what happened to cause this behavior.
When a User is assigned one or more roles within a collection and then access to the collection is "removed" via the "Enable Collection Access" checkbox, the user still has the permissions granted by all roles within the Collection. This means that if one or more of the roles grant access to the collection, then the user will still have collection access.

Take for example the following scenario:

  • A user has Collection Access and the Collection Admin role to Collection A
    • "Collection Access" means they have the User-Level policy /system/sp7/collection resource and access action for the desired collection
    • The "Collection Admin" role grants a user all permissions (the % resource and % action) to the desired Collection
  • Collection Access is removed from Collection A via the "Enable Collection Access" checkbox, but the Collection Admin role is not removed
  • The user would still have all permissions in Collection A, because they still have the Collection Admin role assigned in that collection

The below video demonstrates this behavior in the application:

Screen.Recording.2026-04-22.at.8.39.33.AM.mov
SQL Queries to retreive permissions

Query to fetch all permissions assigned by roles:

SELECT user.SpecifyUserID AS 'User ID',
    user.Name AS 'User Name',
    sprole.Name AS 'Role Name',
    rolepol.resource AS 'Role Resource',
    rolepol.action AS 'Role Action',
    rolcol.UserGroupScopeID AS 'Role Collection ID',
    rolcol.CollectionName AS 'Role Collection Name'
FROM specifyuser user
    LEFT OUTER JOIN spuserrole ON spuserrole.specifyuser_id = user.SpecifyUserID
    LEFT OUTER JOIN sprole ON sprole.id = spuserrole.role_id
    LEFT OUTER JOIN sprolepolicy rolepol ON rolepol.role_id = sprole.id
    LEFT OUTER JOIN collection rolcol ON rolcol.UserGroupScopeID = sprole.collection_id;

Query to fetch all permissions assigned as the user level:

SELECT user.SpecifyUserID AS 'User ID',
    policy.resource AS 'User Policy Resource',
    policy.action AS 'User Policy Action',
    col.UserGroupScopeID AS 'Collection ID',
    col.collectionname AS 'User Policy Collection Name'
FROM specifyuser user
    JOIN spuserpolicy policy ON policy.specifyuser_id = user.SpecifyUserID
    LEFT OUTER JOIN collection col ON col.UserGroupScopeID = policy.collection_id;

In other words, the "Enable Collection Access" checkbox only removes the User-Level policy that grants collection access. I assume the intended behavior is for it to also remove all assigned roles within that collection?

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

Labels

None yet

Projects

Status: Dev Attention Needed

Development

Successfully merging this pull request may close these issues.

Improve API authentication

3 participants