juanfont/headscale · error

invalid oauth access token: %w

Error message

invalid oauth access token: %w

What it means

AuthenticateAccessToken found a token row by prefix but verifySecret rejected the presented secret half: either errSecretMismatch (wrong token — hash comparison failed) or errSecretHashMalformed (stored Hash column is not a valid PHC string). Expiry and client-binding checks come after, so this error isolates the credential check itself.

Source

Thrown at hscontrol/db/oauth.go:353

	}

	prefix, secret, err := parsePrefixedKey(
		rest,
		accessTokenPrefixLength,
		accessTokenSecretLength,
		ErrAccessTokenFailedToParse,
	)
	if err != nil {
		return nil, err
	}

	var token types.OAuthAccessToken
	if err := hsdb.DB.First(&token, "prefix = ?", prefix).Error; err != nil { //nolint:noinlineerr
		return nil, ErrAccessTokenNotFound
	}

	if err := verifySecret(token.Hash, secret); err != nil { //nolint:noinlineerr
		return nil, fmt.Errorf("invalid oauth access token: %w", err)
	}

	if token.Expiration != nil && token.Expiration.Before(time.Now()) {
		return nil, ErrAccessTokenExpired
	}

	// Bind validity to the issuing client: a token whose client has been
	// revoked or deleted is rejected. This closes a mint/revoke race (where a
	// token could be inserted after the client's tokens were purged) and any
	// orphan left by manual deletion or a future soft-revoke path.
	var client types.OAuthClient
	if err := hsdb.DB.First(&client, "client_id = ?", token.ClientID).Error; err != nil { //nolint:noinlineerr
		return nil, ErrAccessTokenClientRevoked
	}

	if client.Revoked != nil {
		return nil, ErrAccessTokenClientRevoked
	}

View on GitHub (pinned to 565fd254d0)

Solutions

  1. Mint a fresh access token and replace the stored credential
  2. Verify the stored Hash looks like a valid $argon2id$ PHC string with intact base64
  3. Ensure the token is passed whole in the Authorization header

Example fix

// before
if err != nil {
	return err
}

// after
switch {
case errors.Is(err, db.ErrAccessTokenNotFound):
	return errUnauthorized("unknown token")
case err != nil: // includes invalid-token and expiry
	return errUnauthorized("invalid or expired token")
}
Defensive patterns

Strategy: try-catch

Type guard

func isTokenRejected(err error) bool {
	return errors.Is(err, db.ErrAccessTokenNotFound) ||
		errors.Is(err, db.ErrAccessTokenExpired) ||
		errors.Is(err, db.ErrAccessTokenClientRevoked) ||
		err != nil && strings.Contains(err.Error(), "invalid oauth access token")
}

Try / catch

if _, err := hsdb.AuthenticateAccessToken(bearer); err != nil {
	if isTokenRejected(err) {
		return errUnauthorized
	}
	return err // genuine 500 (DB issue)
}

Prevention

When it happens

Trigger: Bearer token from a different installation or a rotated-away token; token string truncated or with stray characters; Hash column corrupted.

Common situations: Environments copied between prod/staging sharing prefix configuration; secrets mangled by YAML/env serialization (line breaks in the base64).

Related errors


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