Allow authentication by JWT Bearer token#7826
Conversation
Triggered by 3561173 on branch refs/heads/issue-5163
acwhite211
left a comment
There was a problem hiding this comment.
Nice improvement to our API authentication 👍
Triggered by 7d75944 on branch refs/heads/issue-5163
There was a problem hiding this comment.
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
grantfitzsimmons
left a comment
There was a problem hiding this comment.
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
Authorizationheader of the request toBearer <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
Authorizationheader of the request toBearer <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:
❯ 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/"}% ~ ❯
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:
/context/login/).X-CSRFTokenheader with the each unsafe request made to the backend/context/login/with the user's name, password, and collection idFor an example, the prior workflow can be modeled by something like the following Python pseudo code (inspired by the requests library):
With the new approach, users of the API only require:
/context/login/GET endpoint)/accounts/token/with their username, password, and desired collection id to retrieve an access tokenOverview
Acquiring an Access Token
Access tokens can be acquired by sending a POST request to
/accounts/token/and passing theusername,password,collectionid, and optionallyexpires.If the request is successful, the access token is retrievable by the
access_tokenkey 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:In the above case, the resulting access token is
eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOjEsInVzZXJuYW1lIjoic3BmaXNoYWRtaW4iLCJjb2xsZWN0aW9uIjo0LCJqdGkiOiI2NWMzMmYwNy1hYTMzLTQxN2MtYjI2Ny02MDQwOGQyOTQ0ZjYiLCJpYXQiOjE3NzM5NDUxODIsImV4cCI6MTc3Mzk0Njk4Mn0.s3FTc9EeObiSmm9FLywlpdHkXMKiAob1QuVkW8pp3_o.Example with Python requests
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
expiresattribute when requesting the token. The backend expectsexpiresto 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:
Using an Access Token
Once an access token is generated, it can be used by passing it in subsequent requests by the
Authorizationheader with theBearerscheme.In other words, the general form of the
Authorizationheader should look likeAuthorizarion: 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:
Example of creating a new agent using Python requests:
If the token is invalid, expired, or revoked then Specify will return a 401 Unauthorized response with the
WWW-Authenticateheaders indicating an invalid token: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 anaccess_tokenkey.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:
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)
Checklist
self-explanatory (or properly documented)
TODO
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 collectionEnsure 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
Authorizationheader of the request toBearer <my_token>, replacing<my_token>with your access tokenEnsure 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
Authorizationheader of the request toBearer <my_token>, replacing<my_token>with your access tokenEnsure 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