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 writtenView on GitHub (pinned to 565fd254d0)
Solutions
- Address the wrapped DB error (privileges, read-only mount, space, locks)
- Check for and remove leftover oauth_access_tokens objects from earlier failed attempts, then restart
- 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
- Remove leftover experimental v2-API tables before the official upgrade
- Verify disk space and writability in the DB volume
- Single headscale instance per database during DDL
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
- creating oauth_clients table: %w
- adding column types.Node: %w
- dropping routes table: %w
- renaming table %s to %s_old: %w
- creating new table: %w
AI-assisted analysis of juanfont/headscale@565fd254d0 (2026-08-15).
Data as JSON: /api/errors/16046bff970253b8.
Report an issue: GitHub.