juanfont/headscale · critical

creating oauth_access_tokens table: %w

Error message

creating oauth_access_tokens table: %w

What it means

The oauth migration fails creating the oauth_access_tokens table (id, prefix, hash, client_id, scopes, tags, expiration, created_at) backing bearer-token validation for the v2 OAuth flow. Same failure class as the oauth_clients CREATE TABLE: privileges, read-only database, disk full, catalog collision, or an aborted transaction masking an earlier error.

Source

Thrown at hscontrol/db/db.go:890

						err = tx.Exec(`CREATE UNIQUE INDEX idx_oauth_clients_client_id ON oauth_clients(client_id)`).Error
						if err != nil {
							return fmt.Errorf("creating oauth_clients index: %w", err)
						}
					}

					if !tx.Migrator().HasTable(&types.OAuthAccessToken{}) {
						err := tx.Exec(`CREATE TABLE oauth_access_tokens(
  id integer PRIMARY KEY AUTOINCREMENT,
  prefix text,
  hash blob,
  client_id text,
  scopes text,
  tags text,
  expiration datetime,
  created_at datetime
)`).Error
						if err != nil {
							return fmt.Errorf("creating oauth_access_tokens table: %w", err)
						}

						err = tx.Exec(`CREATE UNIQUE INDEX idx_oauth_access_tokens_prefix ON oauth_access_tokens(prefix)`).Error
						if err != nil {
							return fmt.Errorf("creating oauth_access_tokens index: %w", err)
						}
					}

					return nil
				},
				Rollback: func(db *gorm.DB) error { return nil },
			},
			{
				// Clear stale key expiry on tagged nodes. A tagged node is
				// owned by its tags and never expires (KB 1068), but a buggy
				// handleLogout stamped a past expiry on it, leaving it
				// permanently Expired and unable to re-authenticate. The
				// buggy writer is fixed, so this only repairs rows written

View on GitHub (pinned to 565fd254d0)

Solutions

  1. Address the wrapped DB error (privileges, read-only mount, space, locks)
  2. Check for and remove leftover oauth_access_tokens objects from earlier failed attempts, then restart
  3. Confirm after startup that both oauth tables exist alongside their indexes
Defensive patterns

Strategy: validation

Validate before calling

// Pre-flight: catalog clean and DDL permitted
var collision bool
db.QueryRow(`SELECT EXISTS (SELECT 1 FROM sqlite_master
	WHERE name = 'oauth_access_tokens' AND type <> 'table')`).Scan(&collision)
if collision {
	log.Fatal("non-table object named oauth_access_tokens exists")
}
if _, err := db.Exec("CREATE TABLE IF NOT EXISTS _probe(id integer)"); err != nil {
	log.Fatalf("DDL blocked: %v", err)
}

Prevention

When it happens

Trigger: HasTable(&types.OAuthAccessToken{}) is false and the raw CREATE TABLE is rejected by the database due to environment problems or leftover catalog objects.

Common situations: First upgrade to a v2-API-capable headscale on a constrained or drifted database; Postgres roles without CREATE; Docker containers with full volumes.

Related errors


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