TypeScript

Sign an OAuth 1.0a request in plain Node

Posting to X with user-context creds means signing the request yourself. Here is the HMAC-SHA1 signature, built by hand, no library.

21 Jun 2026

The signature base string is the whole game. Get the percent-encoding and the sort order right and the rest falls out.

import { createHmac, randomBytes } from "node:crypto";
 
// Stricter than encodeURIComponent: OAuth also escapes ! * ' ( )
function pctEncode(value: string): string {
  return encodeURIComponent(value).replace(
    /[!*'()]/g,
    (c) => "%" + c.charCodeAt(0).toString(16).toUpperCase(),
  );
}
 
interface Creds {
  consumerKey: string;
  consumerSecret: string;
  token: string;
  tokenSecret: string;
}
 
export function authHeader(
  method: string,
  baseUrl: string,
  creds: Creds,
): string {
  const oauth: Record<string, string> = {
    oauth_consumer_key: creds.consumerKey,
    oauth_nonce: randomBytes(16).toString("hex"),
    oauth_signature_method: "HMAC-SHA1",
    oauth_timestamp: Math.floor(Date.now() / 1000).toString(),
    oauth_token: creds.token,
    oauth_version: "1.0",
  };
 
  // Sort by encoded key, then join as k=v with &.
  const sortedEncodedParams = Object.keys(oauth)
    .map((k) => [pctEncode(k), pctEncode(oauth[k])])
    .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
    .map(([k, v]) => `${k}=${v}`)
    .join("&");
 
  const baseString =
    method.toUpperCase() +
    "&" +
    pctEncode(baseUrl) +
    "&" +
    pctEncode(sortedEncodedParams);
 
  const signingKey =
    pctEncode(creds.consumerSecret) + "&" + pctEncode(creds.tokenSecret);
 
  oauth.oauth_signature = createHmac("sha1", signingKey)
    .update(baseString)
    .digest("base64");
 
  return (
    "OAuth " +
    Object.keys(oauth)
      .sort()
      .map((k) => `${pctEncode(k)}="${pctEncode(oauth[k])}"`)
      .join(", ")
  );
}

Gotchas

When you POST a JSON body, the body is not part of the signature. Only the oauth_* params (and any query-string params) go into the base string. People burn an afternoon hashing the body in and getting a 401; per OAuth 1.0a, a non-form-encoded body is correctly left out.

Related
now runningwhisper_scheduleopen