juanfont/headscale · error · gorm.ErrRecordNotFound
ErrAccessTokenNotFound
ErrAccessTokenNotFound
Error message
oauth access token not found: %w
What it means
ErrAccessTokenNotFound wraps gorm.ErrRecordNotFound for OAuth access tokens: a First() by the token's 12-char prefix found no row. As a sentinel wrapping gorm's not-found, it should be checked with errors.Is. Distinct from a token that exists but fails verification or is expired, which return different errors.
Source
Thrown at hscontrol/db/oauth.go:41
// 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 * 1024
argon2Threads = 1
argon2KeyLen = 32
argon2SaltLen = 16
)View on GitHub (pinned to 565fd254d0)
Solutions
- Handle with errors.Is(err, db.ErrAccessTokenNotFound) as 401
- Mint a new access token via the client credential flow
- Confirm the Authorization header carries the full hskey-oauthtok-... string
Example fix
// before
client, err := hsdb.AuthenticateAccessToken(tok)
if err != nil {
return err // 500 on unknown token
}
// after
if errors.Is(err, db.ErrAccessTokenNotFound) {
return ErrUnauthorized // 401 with WWW-Authenticate
} Defensive patterns
Strategy: try-catch
Type guard
func isAccessTokenNotFound(err error) bool {
return errors.Is(err, db.ErrAccessTokenNotFound)
} Try / catch
if _, err := hsdb.AuthenticateAccessToken(bearer); err != nil {
if errors.Is(err, db.ErrAccessTokenNotFound) {
w.Header().Set("WWW-Authenticate", "Bearer")
http.Error(w, "invalid token", http.StatusUnauthorized)
return
}
return err
} Prevention
- Re-mint tokens when an OAuth client is revoked — all its tokens die with it
- Pass the full hskey-oauthtok-... string in the header
- Log token prefixes, never full secrets, when debugging
When it happens
Trigger: Presenting a bearer token whose prefix is not in the database; token deleted by RevokeOAuthClient purging all of a client's tokens; malformed token string that parses but derives an unknown prefix.
Common situations: Long-lived API client holding a token after the oauth client was revoked; rotated tokens not propagated; truncated bearer header.
Related errors
- ErrOAuthClientNotFound
- invalid oauth access token: %w
- failed to parse oauth client secret
- failed to parse oauth access token
- oauth access token expired
AI-assisted analysis of juanfont/headscale@565fd254d0 (2026-08-15).
Data as JSON: /api/errors/c1b164b5231e44d3.
Report an issue: GitHub.