はじめに
Google Apps Script(GAS)からX API v2を使ってツイート投稿機能を実装したとき、認証テスト(GET /users/me)は通るのにPOST /tweetsだけが401で弾かれるという現象に3時間ハマりました。
原因は OAuth 1.0a の署名生成ロジックにあった「HMAC-SHA1宣言なのにSHA256で計算していた」という凡ミスです。本記事では同じ罠にハマる人を減らすため、原因・GAS APIの紛らわしい点・正しい実装・切り分け手順をまとめます。
環境情報
| 項目 | バージョン / 値 |
|---|---|
| ランタイム | Google Apps Script(V8) |
| API | X API v2(旧Twitter API) |
| 認証方式 | OAuth 1.0a User Context |
| エンドポイント(成功) | GET https://api.twitter.com/2/users/me |
| エンドポイント(失敗) | POST https://api.twitter.com/2/tweets |
| 利用関数 |
Utilities.computeHmacSignature / Utilities.computeHmacSha256Signature
|
症状
-
GET /2/users/me→ 200 OK(認証通る) -
POST /2/tweets→ 401 Unauthorized(Unauthorizedメッセージのみ) - API Key / API Secret / Access Token / Access Token Secret はいずれも正しい
- タイムスタンプ・nonce・パーセントエンコーディングも仕様通り
「認証ヘッダのどこかが間違っている」のは確実だが、GETは通るので資格情報自体は生きている――ここで思考が止まりやすいポイントです。
原因:宣言した署名方式と実際の計算アルゴリズムの不一致
OAuth 1.0a の Authorization ヘッダには以下のように署名方式を宣言します。
oauth_signature_method="HMAC-SHA1"
ところが署名計算側のコードでこうなっていました。
// NG: 宣言は HMAC-SHA1 なのに SHA256 で計算している
const signature = Utilities.computeHmacSha256Signature(
signatureBaseString,
signingKey
);
X APIサーバーは「HMAC-SHA1で署名しているはず」として検証するため、SHA256で生成した署名は永遠に一致しません。結果として401が返ります。
なぜGETは通ってPOSTで落ちたのか
GET検証で偶然通ったわけではなく、実装初期は別のエンドポイント用に正しいコードを書いていて、ツイート投稿用に書き換えた際にコピペで computeHmacSha256Signature 版を使ってしまったというのが真相でした。
OAuth 1.0a の仕様上、署名方式の不一致はメソッドに関係なく401になります。ただし切り分け時に「GETは通るからコードは正しい」と誤認しやすいので注意が必要です。
GAS APIの紛らわしさ
GASの Utilities には HMAC 系関数が複数あり、名前と挙動が混乱を招きます。
| 関数 | アルゴリズム | 第2引数のアルゴリズム指定 |
|---|---|---|
Utilities.computeHmacSignature(algorithm, value, key) |
引数で選択(SHA1, SHA256, MD5など) |
Utilities.MacAlgorithm.HMAC_SHA_1 等 |
Utilities.computeHmacSha256Signature(value, key) |
SHA256固定 | なし |
Utilities.computeDigest(algorithm, value) |
ハッシュ関数(HMACではない) | - |
computeHmacSignature と computeHmacSha256Signature が別物であることを忘れて補完候補から雑に選ぶと、今回のような事故が起きます。
正しい実装
function generateOAuthSignature(method, url, params, consumerSecret, tokenSecret) {
// 1. パラメータをアルファベット順にソート&連結
const sortedParams = Object.keys(params)
.sort()
.map(k => `${encodeRFC3986(k)}=${encodeRFC3986(params[k])}`)
.join('&');
// 2. シグネチャベースストリングの構築
const signatureBaseString = [
method.toUpperCase(),
encodeRFC3986(url),
encodeRFC3986(sortedParams)
].join('&');
// 3. 署名キー
const signingKey =
`${encodeRFC3986(consumerSecret)}&${encodeRFC3986(tokenSecret || '')}`;
// 4. ★ HMAC-SHA1 で署名(ここが重要)
const signatureBytes = Utilities.computeHmacSignature(
Utilities.MacAlgorithm.HMAC_SHA_1,
signatureBaseString,
signingKey
);
return Utilities.base64Encode(signatureBytes);
}
// RFC 3986 準拠のパーセントエンコード
function encodeRFC3986(str) {
return encodeURIComponent(str)
.replace(/[!'()*]/g, c => '%' + c.charCodeAt(0).toString(16).toUpperCase());
}
POST投稿の呼び出し側はこうなります。
function postTweet(text) {
const url = 'https://api.twitter.com/2/tweets';
const method = 'POST';
const oauthParams = {
oauth_consumer_key: CONSUMER_KEY,
oauth_nonce: Utilities.getUuid().replace(/-/g, ''),
oauth_signature_method: 'HMAC-SHA1', // ← 宣言
oauth_timestamp: Math.floor(Date.now() / 1000).toString(),
oauth_token: ACCESS_TOKEN,
oauth_version: '1.0'
};
// 注意: POST /2/tweets のbodyはJSONなので、
// 署名対象パラメータには oauth_* のみを含める(クエリ・bodyは含めない)
oauthParams.oauth_signature = generateOAuthSignature(
method, url, oauthParams, CONSUMER_SECRET, ACCESS_TOKEN_SECRET
);
const authHeader = 'OAuth ' + Object.keys(oauthParams)
.sort()
.map(k => `${encodeRFC3986(k)}="${encodeRFC3986(oauthParams[k])}"`)
.join(', ');
const response = UrlFetchApp.fetch(url, {
method: 'post',
contentType: 'application/json',
headers: { Authorization: authHeader },
payload: JSON.stringify({ text }),
muteHttpExceptions: true
});
Logger.log(response.getResponseCode());
Logger.log(response.getContentText());
}
ハマりポイントまとめ
エンジニアが踏みやすい地雷を整理しました。
- 署名方式の宣言と計算アルゴリズムの不一致(今回の犯人)
-
application/jsonの POST では bodyを署名対象に含めない(v2の仕様)。v1.1のapplication/x-www-form-urlencoded時代と異なる - パーセントエンコードは
encodeURIComponentだけでは不十分。!'()*を追加変換しないと一部の値で署名がズレる -
oauth_timestampがサーバー時刻と大きくズレている(GASサーバーは通常正確だが、テストで固定値を使うと死ぬ) -
oauth_nonceの使い回し(連続実行時に被るとCould not authenticate youが出ることがある)
FAQ
Q. なぜGETは通ったのに気付けなかった?
A. GETは別関数で実装していて、そちらは computeHmacSignature(HMAC_SHA_1, ...) で正しく書けていたため。今回のミスはPOST用関数のコピペ改変時に混入。
Q. SHA256対応のOAuth 1.0a実装はないの?
A. RFC 5849では HMAC-SHA1 RSA-SHA1 PLAINTEXT のみ。X API は OAuth 1.0a で SHA1 一択です。SHA256にしたい場合は OAuth 2.0(Bearer / PKCE)に切り替える必要があります。
Q. 切り分けで最初にやるべきことは?
A. 401を受けたら以下の順で確認すると速いです。
-
oauth_signature_methodの文字列 - 署名計算で呼んでいる関数名(
computeHmacSignatureかcomputeHmacSha256Signatureか) - シグネチャベースストリングを
Logger.logで出力し、X公式のOAuth Signature Generatorの出力と1文字単位で比較
おわりに
「GETは通るのにPOSTだけ401」 は、認証情報ではなく 署名生成ロジックの不整合 を疑うのが最短ルートです。GASの computeHmacSignature と computeHmacSha256Signature の見分けをつけ、宣言と計算を一致させましょう。同じ3時間を誰かが失わずに済めば幸いです。
この記事を書いた人
BENTEN Web Works — 業務自動化・システム開発のフリーランスエンジニアです。
GAS / Python / RPA を使った業務自動化や、Web制作・システム開発のご相談を承っています。
「こんなこと自動化できる?」というご質問だけでもお気軽にどうぞ。
👉 業務自動化サービス — 詳細・お問い合わせはこちら
🐦 X(旧Twitter) — 日々の知見を発信中