{"record":{"id":"6e100f741362864f","repo":"juanfont/headscale","slug":"failed-to-parse-oauth-client-secret","errorCode":null,"errorMessage":"failed to parse oauth client secret","messagePattern":"failed to parse oauth client secret","errorType":"exception","errorClass":null,"httpStatus":null,"severity":"error","filePath":"hscontrol/db/oauth.go","lineNumber":38,"sourceCode":"\nconst (\n\t// OAuth client secret: hskey-client-<clientID(12)>-<secret(64)>. The clientID\n\t// is the public, indexed lookup key (the analogue of an API key's prefix) and\n\t// is embedded in the secret so the token endpoint can derive it. The prefix\n\t// itself lives in the types package ([types.OAuthClientPrefix]).\n\toauthClientIDLength     = 12\n\toauthClientSecretLength = 64\n\n\t// OAuth access token: hskey-oauthtok-<prefix(12)>-<secret(64)>. The distinct\n\t// prefix (vs hskey-api- admin keys, [types.AccessTokenPrefix]) lets the auth\n\t// middleware dispatch a scoped token from an all-access admin key alone.\n\taccessTokenPrefixLength = 12\n\taccessTokenSecretLength = 64\n)\n\nvar (\n\tErrOAuthClientNotFound      = fmt.Errorf(\"oauth client not found: %w\", gorm.ErrRecordNotFound)\n\tErrOAuthClientFailedToParse = errors.New(\"failed to parse oauth client secret\")\n\tErrOAuthClientRevoked       = errors.New(\"oauth client revoked\")\n\n\tErrAccessTokenNotFound      = fmt.Errorf(\"oauth access token not found: %w\", gorm.ErrRecordNotFound)\n\tErrAccessTokenFailedToParse = errors.New(\"failed to parse oauth access token\")\n\tErrAccessTokenExpired       = errors.New(\"oauth access token expired\")\n\tErrAccessTokenClientRevoked = errors.New(\"oauth access token issuing client revoked or deleted\")\n\n\terrSecretHashMalformed = errors.New(\"malformed secret hash\")\n\terrSecretMismatch      = errors.New(\"secret does not match hash\")\n)\n\n// Argon2id parameters, OWASP's minimum recommendation (19 MiB, 2 iterations, 1\n// lane). They are encoded into every stored hash, so raising them later still\n// verifies credentials stored under the old cost.\nconst (\n\targon2Time    = 2\n\targon2Memory  = 19 * 1024\n\targon2Threads = 1","sourceCodeStart":20,"sourceCodeEnd":56,"githubUrl":"https://github.com/juanfont/headscale/blob/565fd254d06c4c7f9a8cad1714a43445c79ba420/hscontrol/db/oauth.go#L20-L56","documentation":"Sentinel in hscontrol/db/oauth.go rejecting a malformed OAuth client secret during lookup/verification. Client credentials have a fixed shape: hskey-oauthcli- style prefix with a 12-char prefix and 64-char secret (and a legacy shorter format accepted). Parsing fails before any Argon2id hash comparison happens.","triggerScenarios":"Passing an empty string, a secret without the expected prefix, one whose prefix/secret segments have the wrong length, or a credential of another type (API key, auth-key, access token) to the OAuth client secret verifier.","commonSituations":"Automation storing the client secret with a trailing newline or truncated by shell word-splitting; pasting an oauth access token where the client secret belongs.","solutions":["Re-copy the client secret exactly as shown at creation; trim whitespace when loading it","Confirm the credential kind matches the endpoint (client secret vs hskey-oauthtok access token)","If the secret was regenerated, update all consumers — old secrets are revoked"],"exampleFix":null,"handlingStrategy":"type-guard","validationCode":"const oauthSecretPrefix = \"hskey-oauthcli-\" // per repo constants\nif !strings.HasPrefix(secret, oauthSecretPrefix) {\n    return errors.New(\"not an OAuth client secret\")\n}","typeGuard":"func isOAuthClientSecret(s string) bool {\n    rest, ok := strings.CutPrefix(s, \"hskey-oauthcli-\")\n    if !ok {\n        return false\n    }\n    prefix, secretPart, found := strings.Cut(rest, \"-\")\n    return found && len(prefix) == 12 && len(secretPart) == 64\n}","tryCatchPattern":"client, err := db.GetOAuthClientBySecret(secret)\nif err != nil {\n    if errors.Is(err, db.ErrOAuthClientFailedToParse) {\n        return unauthorized(\"malformed OAuth client secret\") // 401, no retry\n    }\n    if errors.Is(err, db.ErrOAuthClientNotFound) { /* also 401 */ }\n    return err\n}","preventionTips":["Keep credential prefixes distinct per kind and dispatch on prefix at ingress","Trim secrets when loading from env/secret stores","Rotate secrets through the CLI, then update consumers atomically"],"tags":["oauth","authentication","validation","headscale","go"],"backgroundTag":null,"analyzedSha":"565fd254d06c4c7f9a8cad1714a43445c79ba420","analyzedAt":"2026-08-15T13:12:30.133Z","schemaVersion":2},"datasetVersion":"2026-08-15T17:31:12.345Z"}