juanfont/headscale · error
saving oauth access token: %w
Error message
saving oauth access token: %w
What it means
CreateAccessToken saves the new token row in a transaction that also verifies the issuing client exists and is unrevoked. This wrapper can therefore carry three distinct failures: a DB error on First (availability), ErrOAuthClientNotFound/ErrOAuthClientRevoked (client deleted or revoked in a race), or a DB error on Save (constraint/connectivity). Unwrap to distinguish; the token string is not returned on failure.
Source
Thrown at hscontrol/db/oauth.go:318
// Mint inside a transaction that re-checks the client still exists and is
// not revoked, so a mint cannot complete against a client being deleted.
err = hsdb.Write(func(tx *gorm.DB) error {
var client types.OAuthClient
err := tx.First(&client, "client_id = ?", clientID).Error
if err != nil {
return ErrOAuthClientNotFound
}
if client.Revoked != nil {
return ErrOAuthClientRevoked
}
return tx.Save(&token).Error
})
if err != nil {
return "", nil, fmt.Errorf("saving oauth access token: %w", err)
}
return tokenStr, &token, nil
}
// AuthenticateAccessToken validates a presented bearer token and returns the
// matching, unexpired [types.OAuthAccessToken] (carrying its granted scopes and
// tags). A non-nil error means the token is missing, malformed, or expired.
func (hsdb *HSDatabase) AuthenticateAccessToken(tokenStr string) (*types.OAuthAccessToken, error) {
if tokenStr == "" {
return nil, ErrAccessTokenFailedToParse
}
_, rest, found := strings.Cut(tokenStr, types.AccessTokenPrefix)
if !found {
return nil, ErrAccessTokenFailedToParse
}
View on GitHub (pinned to 565fd254d0)
Solutions
- Unwrap: errors.Is(err, db.ErrOAuthClientNotFound) or ErrOAuthClientRevoked means re-authenticate/re-create the client
- For DB-level causes, fix availability and retry minting
- Serialize revocation and minting through one admin path
Example fix
// before
_, tok, err := hsdb.CreateAccessToken(clientID, ...)
if err != nil {
return err
}
// after
_, tok, err := hsdb.CreateAccessToken(clientID, ...)
if errors.Is(err, db.ErrOAuthClientNotFound) || errors.Is(err, db.ErrOAuthClientRevoked) {
return fmt.Errorf("client %s revoked or deleted; re-create it", clientID)
} Defensive patterns
Strategy: try-catch
Validate before calling
// Confirm client exists and is unrevoked before minting
if c, err := hsdb.GetOAuthClientByClientID(clientID); err != nil || c.Revoked != nil {
return fmt.Errorf("client %s unavailable", clientID)
} Type guard
func isClientStateError(err error) bool {
return errors.Is(err, db.ErrOAuthClientNotFound) ||
errors.Is(err, db.ErrOAuthClientRevoked)
} Try / catch
_, tok, err := hsdb.CreateAccessToken(clientID, ...)
if err != nil {
if isClientStateError(err) {
// client revoked mid-flight: surface actionable error
return errClientRevoked
}
if isTransientDBError(err) {
// safe to retry; token minted only on success
}
return err
} Prevention
- Never cache 'client is alive' across long-lived sessions
- Discard token strings from failed calls
- Treat revoke and mint as mutually exclusive admin operations
When it happens
Trigger: Client revoked between the caller's check and token creation; unique-constraint hit on token prefix (astronomically rare); DB lock/timeout.
Common situations: Concurrent revoke + token mint from different admin sessions; automations caching client state across revocation.
Related errors
- renaming node in database: %w
- ErrAccessTokenNotFound
- saving oauth client: %w
- deleting oauth access tokens: %w
- invalid oauth access token: %w
AI-assisted analysis of juanfont/headscale@565fd254d0 (2026-08-15).
Data as JSON: /api/errors/db1e8ce18cbc913f.
Report an issue: GitHub.