{"record":{"id":"919888da6b51d26f","repo":"juanfont/headscale","slug":"invalid-oauth-client-secret-w","errorCode":null,"errorMessage":"invalid oauth client secret: %w","messagePattern":"invalid oauth client secret: %w","errorType":"validation","errorClass":null,"httpStatus":401,"severity":"error","filePath":"hscontrol/db/oauth.go","lineNumber":216,"sourceCode":"\t}\n\n\tclientID, secret, err := parsePrefixedKey(\n\t\trest,\n\t\toauthClientIDLength,\n\t\toauthClientSecretLength,\n\t\tErrOAuthClientFailedToParse,\n\t)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tvar client types.OAuthClient\n\tif err := hsdb.DB.First(&client, \"client_id = ?\", clientID).Error; err != nil { //nolint:noinlineerr\n\t\treturn nil, ErrOAuthClientNotFound\n\t}\n\n\tif err := verifySecret(client.SecretHash, secret); err != nil { //nolint:noinlineerr\n\t\treturn nil, fmt.Errorf(\"invalid oauth client secret: %w\", err)\n\t}\n\n\tif client.Revoked != nil {\n\t\treturn nil, ErrOAuthClientRevoked\n\t}\n\n\treturn &client, nil\n}\n\n// GetOAuthClientByClientID returns a [types.OAuthClient] by its public client id.\nfunc (hsdb *HSDatabase) GetOAuthClientByClientID(clientID string) (*types.OAuthClient, error) {\n\tvar client types.OAuthClient\n\tif result := hsdb.DB.First(&client, \"client_id = ?\", clientID); result.Error != nil {\n\t\treturn nil, result.Error\n\t}\n\n\treturn &client, nil\n}","sourceCodeStart":198,"sourceCodeEnd":234,"githubUrl":"https://github.com/juanfont/headscale/blob/565fd254d06c4c7f9a8cad1714a43445c79ba420/hscontrol/db/oauth.go#L198-L234","documentation":"AuthenticateOAuthClient found the client row but verifySecret rejected the presented secret. The wrapped error is either errSecretMismatch (argon2 hash comparison failed — wrong secret) or errSecretHashMalformed (stored hash is not a valid PHC string — corrupted data or legacy format). Revocation is checked after, so this error specifically means the credential itself failed.","triggerScenarios":"Wrong or rotated client secret; secret copied with whitespace/newline; SecretHash column corrupted by manual edits or a truncated column type.","commonSituations":"Stale secret in CI/CD variables after rotation; database dumped/restored with encoding loss breaking the base64 in the hash.","solutions":["Regenerate the client secret and update the consumer","Check the stored hash round-trips: it must look like $argon2id$v=19$m=...$salt$hash with valid base64","Audit for manual writes to the oauth_clients table"],"exampleFix":"// before\nclient, err := hsdb.AuthenticateOAuthClient(secret)\nif err != nil {\n\tlog.Fatal(err)\n}\n\n// after\nif err != nil {\n\tvar msg string\n\tif errors.Is(err, db.ErrOAuthClientNotFound) {\n\t\tmsg = \"unknown client\"\n\t} else {\n\t\tmsg = \"invalid client credentials\"\n\t}\n\thttp.Error(w, msg, http.StatusUnauthorized)\n\treturn\n}","handlingStrategy":"try-catch","validationCode":null,"typeGuard":"func isOAuthCredentialRejected(err error) bool {\n\treturn err != nil && !errors.Is(err, db.ErrOAuthClientNotFound)\n}","tryCatchPattern":"if _, err := hsdb.AuthenticateOAuthClient(secret); err != nil {\n\tswitch {\n\tcase errors.Is(err, db.ErrOAuthClientNotFound):\n\t\treturn errUnknownClient\n\tcase errors.Is(err, db.ErrOAuthClientRevoked):\n\t\treturn errRevokedClient\n\tdefault: // includes invalid secret and malformed hash\n\t\treturn errBadCredentials\n\t}\n}","preventionTips":["Distinguish wrong-secret from corrupted-hash by unwrapping errSecretMismatch vs errSecretHashMalformed","Store secrets via secret managers that preserve exact bytes","Never hand-edit the secret_hash column"],"tags":["go","oauth","authentication","argon2","credentials"],"backgroundTag":null,"analyzedSha":"565fd254d06c4c7f9a8cad1714a43445c79ba420","analyzedAt":"2026-08-15T13:12:30.133Z","schemaVersion":2},"datasetVersion":"2026-08-15T22:17:37.221Z"}