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

  1. Handle with errors.Is(err, db.ErrAccessTokenNotFound) as 401
  2. Mint a new access token via the client credential flow
  3. 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

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


AI-assisted analysis of juanfont/headscale@565fd254d0 (2026-08-15). Data as JSON: /api/errors/c1b164b5231e44d3. Report an issue: GitHub.