You can track the fix for this vulnerability via the GitHub pull request below.
Description
The refresh_token branch of defguard’s OpenID Connect token endpoint at POST /api/v1/oauth/token issues access tokens without authenticating the OAuth2 client making the request. Any caller holding a refresh token can redeem it by sending only grant_type=refresh_token&refresh_token=<value> with no client_id and no client_secret, even while supplying deliberately wrong credentials. A second defect in the same code path causes the endpoint to return the exact token pair that was submitted rather than rotating it, so the credential stays valid indefinitely through repeated refreshes and any revocation based on token reuse detection is structurally impossible. Each refresh also writes an orphan oauth2token row to the database, gradually polluting the table and undermining the single-row cleanup that fires on re-authorization.
Technical details
According to RFC 6749, section 6:
Because refresh tokens are typically long-lasting credentials used to request additional access tokens, the refresh token is bound to the client to which it was issued. If the client type is confidential or the client was issued client credentials (or assigned other authentication requirements), the client MUST authenticate with the authorization server as described in Section 3.2.1.
The handler at crates/defguard_core/src/handlers/openid_flow.rs:984-988 binds an authenticated client:
pub async fn token(
State(appstate): State<AppState>,
OAuth2ClientExtractor(oauth2client): OAuth2ClientExtractor,
Form(form): Form<TokenRequest>,
) -> ApiResult {
The authorization_code arm consumes that binding. At openid_flow.rs:1006 it refuses to proceed unless a client authenticated, either through HTTP Basic or through the form body fallback:
if let Some(client) = oauth2client.or(form.oauth2client(&appstate.pool).await) {
The refresh_token arm never mentions oauth2client at all. It resolves the client from the attacker’s own input, at openid_flow.rs:1103-1132:
CoreGrantType::RefreshToken => {
debug!("Starting refresh_token flow");
if let Some(refresh_token) = form.refresh_token
&& let Ok(Some(mut token)) =
OAuth2Token::find_refresh_token(&appstate.pool, &refresh_token).await
{
let Some(client) = OAuth2Client::find_by_token(&appstate.pool, &token).await?
else { /* 400 invalid_client */ };
if !client.enabled { /* 400 unauthorized_client */ }
token.refresh_and_save(&appstate.pool).await?;
let response = TokenRequest::refresh_token_flow(&token);
token.save(&appstate.pool).await?;
return Ok(ApiResponse::json(response, StatusCode::OK));
}
}
OAuth2Client::find_by_token at crates/defguard_common/src/db/models/oauth2client.rs:90-103 is a JOIN keyed on the presented token, WHERE t.access_token = $1 OR t.refresh_token = $2. The client identity it returns is a function of what the caller sent, not of any secret the caller proved. The only gate that survives is client.enabled, which tests the state of the client record rather than the identity of the requester. Every defguard OAuth2 client is confidential: OAuth2Client::new at oauth2client.rs:26-38 always generates a 32 character client_secret and there is no public client mode, so RFC 6749 section 6 requires client authentication on this grant.
Supplying deliberately wrong credentials does not help the defense either, because the extractor fails open. At openid_flow.rs:168-192, a Basic header that decodes cleanly is passed to OAuth2Client::find_by_auth, and a None result is wrapped in Ok rather than rejected:
return Ok(Self(
OAuth2Client::find_by_auth(&appstate.pool, client_id, client_secret)
.await
.map_err(WebError::from)?,
));
The second defect turns a one time theft into ongoing access. OAuth2Token::refresh_and_save at crates/defguard_common/src/db/models/oauth2token.rs:35-54 generates a new pair, writes it to the row, and updates only one field on self:
pub async fn refresh_and_save(&mut self, pool: &PgPool) -> sqlx::Result<()> {
let new_access_token = gen_alphanumeric(24);
let new_refresh_token = gen_alphanumeric(24);
let expiration = Utc::now() + TimeDelta::seconds(timeout.as_secs().cast_signed());
self.expires_in = expiration.timestamp();
query!(
"UPDATE oauth2token SET access_token = $2, refresh_token = $3, expires_in = $4 \
WHERE access_token = $1",
self.access_token,
new_access_token,
new_refresh_token,
self.expires_in,
)
self.access_token and self.refresh_token keep their old values. TokenRequest::refresh_token_flow at openid_flow.rs:928-941 then serialises those stale in-memory strings into the response, and token.save at openid_flow.rs:1129 runs the INSERT at oauth2token.rs:63-78, writing the old pair back as a brand new row with a refreshed expires_in. The freshly generated pair lands in a row that nobody will ever present. The UNIQUE constraints on access_token and refresh_token are not violated because the UPDATE moved the original values off the old row first.
The finding’s stated precondition is that the attacker already holds a refresh token, for example from a compromised relying party, a Relying Party log, or a database backup. To demonstrate this vulnerability, under OpenID applications, create a new application, for example:

Request:
POST /api/v1/oauth HTTP/1.1
Host: localhost:8000
[...]
{"name":"isec_poc","redirect_uri":["https://isec.pl"],"enabled":true,"scope":["openid","email","profile"]}
Response:
HTTP/1.1 201 Created
content-type: application/json
{
"client_id": "vqsZjDGkKVGaqtZe",
"client_secret": "Q0wtNbfNAd461uLKXYpJo3mTKqMqReid",
"enabled": true,
"id": 4,
"name": "isec_poc",
"redirect_uri": ["https://isec.pl"],
"scope": ["openid", "email", "profile"]
}
Note the oauth2client_id=4 from the response. In this example, the attacker will obtain a refresh token from a user with user_id=1. Now, let’s simulate an attacker who obtained a refresh token:
APP_ID=$(docker compose exec -T db psql -U defguard -d defguard -t -A -c "INSERT INTO oauth2authorizedapp (oauth2client_id, user_id) VALUES (4, 1) RETURNING id;" | head -1 | tr -d $'\r\n ')
EXPIRES=$(python3 -c 'import time;print(int(time.time())+3600)')
docker compose exec -T db psql -U defguard -d defguard -q -c "INSERT INTO oauth2token (access_token, refresh_token, redirect_uri, scope, expires_in, oauth2authorizedapp_id) VALUES ('isec_access_token','isec_refresh_token','https://isec.pl','openid profile email',$EXPIRES,$APP_ID);"
Now an attacker can redeem the refresh token without client credentials (note the lack of Authorization header):
Request:
POST /api/v1/oauth/token HTTP/1.1
Host: localhost:8000
Content-Type: application/x-www-form-urlencoded
grant_type=refresh_token&refresh_token=isec_refresh_token
Response:
HTTP/1.1 200 OK
content-type: application/json
{"access_token":"isec_access_token","refresh_token":"isec_refresh_token","token_type":"bearer"}
It is also possible to redeem the refresh token with wrong credentials (Authorization: Basic aXNlYzppc2Vj) - the response is identical.
Additionally, in the database, orphan rows are created with new values of access_token and refresh_token, but the API will never rotate those tokens since the last row is populated with old values:
~/defguard$ docker compose exec -T db psql -U defguard -d defguard -t -A -c "SELECT * FROM oauth2token"
29|GjqVDB2h5rqHHqbRp0j2L2hb|U2tTZYsSBdehP9n0Ka3nl1Tu|https://isec.pl|openid profile email|1788264122|7
30|kroL7eJRQ5cDRHFXNc0OrZaL|DiiqWwF7d7EIj8TWMQtbDWLg|https://isec.pl|openid profile email|1788264126|7
31|3CLhyQcHpKM8wpRnIQhwPhKL|lL8Go9c4a6cHK4wpNIwKRMck|https://isec.pl|openid profile email|1788264127|7
32|isec_access_token|isec_refresh_token|https://isec.pl|openid profile email|1788264127|7
Impact
Any defguard deployment operating as an OpenID Connect provider is affected. The attacker needs network reach to the API and possession of one valid refresh token, for example from a compromised relying party, an application log, or a database backup. What the attacker receives is durable, credential-free access to one user’s scope-gated OIDC claims at /api/v1/oauth/userinfo: sub, email, name, given_name, family_name, preferred_username, and phone_number. The access token obtained through the vulnerable refresh path is indistinguishable from one issued during a legitimate authorization code flow. Any relying party application - Grafana, Nextcloud, GitLab, or a custom service - that delegates authentication to this Defguard deployment will accept it and create a session for the victim. The attacker therefore gains authenticated access to every application in the organization that trusts this Defguard instance as its OIDC provider, not only to Defguard itself. The scope of compromise scales with the number of integrated relying parties. Access is also not reliably revoked by having the user re-authorize the application, because the orphan row accumulation breaks the single-row cleanup at openid_flow.rs:1032-1040. Refresh tokens expire after authentication_period_days (default seven days) when unused, so credentials from old backups become inert over time.
Recommendations
In the RefreshToken arm of the token handler in crates/defguard_core/src/handlers/openid_flow.rs, resolve the client the same way the authorization_code arm does: oauth2client.or(form.oauth2client(&appstate.pool).await). Return HTTP 401 with invalid_client when nothing authenticated, and reject the request when the authenticated client’s id does not match the client resolved via OAuth2Client::find_by_token. Make OAuth2ClientExtractor::from_request_parts fail closed by returning an error when a Basic header is present but does not resolve to a valid client, rather than silently degrading to None. In OAuth2Token::refresh_and_save, assign new_access_token and new_refresh_token to self before the UPDATE and drop the redundant token.save call at line 1129, so real rotation occurs and superseded tokens can trigger revocation of the token family. Add user.is_active checks on both the refresh path and in userinfo, and emit an activity log event on token redemption, since emit_event currently appears zero times in openid_flow.rs.