juanfont/headscale · error · gorm.ErrRecordNotFound
ErrOAuthClientNotFound
ErrOAuthClientNotFound
Error message
oauth client not found: %w
What it means
ErrOAuthClientNotFound wraps gorm.ErrRecordNotFound: a lookup by client_id (derived from the middle segment of the presented secret) matched no row. It is a sentinel error — callers are expected to test with errors.Is and map it to a 404/401 rather than a 500. A client deleted or revoked outright (hard delete) also yields this.
Source
Thrown at hscontrol/db/oauth.go:37
)
const (
// OAuth client secret: hskey-client-<clientID(12)>-<secret(64)>. The clientID
// is the public, indexed lookup key (the analogue of an API key's prefix) and
// is embedded in the secret so the token endpoint can derive it. The prefix
// itself lives in the types package ([types.OAuthClientPrefix]).
oauthClientIDLength = 12
oauthClientSecretLength = 64
// OAuth access token: hskey-oauthtok-<prefix(12)>-<secret(64)>. The distinct
// prefix (vs hskey-api- admin keys, [types.AccessTokenPrefix]) lets the auth
// middleware dispatch a scoped token from an all-access admin key alone.
accessTokenPrefixLength = 12
accessTokenSecretLength = 64
)
var (
ErrOAuthClientNotFound = fmt.Errorf("oauth client not found: %w", gorm.ErrRecordNotFound)
ErrOAuthClientFailedToParse = errors.New("failed to parse oauth client secret")
ErrOAuthClientRevoked = errors.New("oauth client revoked")
ErrAccessTokenNotFound = fmt.Errorf("oauth access token not found: %w", gorm.ErrRecordNotFound)
ErrAccessTokenFailedToParse = errors.New("failed to parse oauth access token")
ErrAccessTokenExpired = errors.New("oauth access token expired")
ErrAccessTokenClientRevoked = errors.New("oauth access token issuing client revoked or deleted")
errSecretHashMalformed = errors.New("malformed secret hash")
errSecretMismatch = errors.New("secret does not match hash")
)
// Argon2id parameters, OWASP's minimum recommendation (19 MiB, 2 iterations, 1
// lane). They are encoded into every stored hash, so raising them later still
// verifies credentials stored under the old cost.
const (
argon2Time = 2
argon2Memory = 19 * 1024View on GitHub (pinned to 565fd254d0)
Solutions
- Check errors.Is(err, db.ErrOAuthClientNotFound) or errors.Is(err, gorm.ErrRecordNotFound) and treat as invalid credentials (404/401)
- Regenerate the client secret and update the consuming application
- Verify the secret was stored whole (correct length, no whitespace/newline)
Example fix
// before
if err != nil {
log.Fatal(err) // surfaces as opaque failure
}
// after
if errors.Is(err, db.ErrOAuthClientNotFound) {
http.Error(w, "unknown oauth client", http.StatusUnauthorized)
return
} Defensive patterns
Strategy: try-catch
Validate before calling
// Cheap existence pre-check when you control the flow
if _, err := hsdb.GetOAuthClientByClientID(clientID); err != nil {
// regenerate secret / tell user client is gone
} Type guard
func isOAuthClientNotFound(err error) bool {
return errors.Is(err, db.ErrOAuthClientNotFound) ||
errors.Is(err, gorm.ErrRecordNotFound)
} Try / catch
if _, err := hsdb.AuthenticateOAuthClient(secret); err != nil {
if errors.Is(err, db.ErrOAuthClientNotFound) {
return ErrUnauthorizedClientUnknown
}
return err
} Prevention
- Check sentinel errors with errors.Is, never string matching
- Rotate secrets through tooling that stores them whole
- Handle revoked/deleted clients as 401/404, not 500
When it happens
Trigger: Authenticating with a secret whose 12-char prefix does not exist; client was deleted via RevokeOAuthClient; secret truncated or corrupted so the derived client_id is wrong.
Common situations: Scripts holding stale credentials after client rotation; copy-paste losing characters of the long secret; environment variable truncation at 64-char secrets.
Related errors
- ErrAccessTokenNotFound
- ErrPreAuthKeyNotFound
- failed to parse oauth client secret
- invalid oauth client secret: %w
- invalid oauth access token: %w
AI-assisted analysis of juanfont/headscale@565fd254d0 (2026-08-15).
Data as JSON: /api/errors/d4ba1df2917aec77.
Report an issue: GitHub.