Agent Skills: Linear Security Basics

|

UncategorizedID: jeremylongshore/claude-code-plugins-plus-skills/linear-security-basics

Install this agent skill to your local

pnpm dlx add-skill https://github.com/jeremylongshore/claude-code-plugins-plus-skills/tree/HEAD/plugins/saas-packs/linear-pack/skills/linear-security-basics

Skill Files

Browse the full folder contents for linear-security-basics.

Download Skill

Loading file tree…

plugins/saas-packs/linear-pack/skills/linear-security-basics/SKILL.md

Skill Metadata

Name
linear-security-basics
Description
|

Linear Security Basics

Overview

Secure authentication patterns for Linear integrations: API key management, OAuth 2.0 with PKCE, token refresh (mandatory for new apps after Oct 2025), webhook HMAC-SHA256 signature verification, and secret rotation.

Prerequisites

  • Linear account with API access
  • Understanding of environment variables and secret management
  • Familiarity with OAuth 2.0 and HMAC concepts

Instructions

Step 1: Secure API Key Storage

// NEVER hardcode keys
// BAD:
// const client = new LinearClient({ apiKey: "lin_api_xxxx" });

// GOOD: environment variable
import { LinearClient } from "@linear/sdk";

const client = new LinearClient({
  apiKey: process.env.LINEAR_API_KEY!,
});

Environment setup:

# .env (never commit)
LINEAR_API_KEY=lin_api_xxxxxxxxxxxxxxxxxxxxxxxxxxxx
LINEAR_WEBHOOK_SECRET=whsec_xxxxxxxxxxxx

# .gitignore
.env
.env.*
!.env.example

# .env.example (commit for documentation)
LINEAR_API_KEY=lin_api_your_key_here
LINEAR_WEBHOOK_SECRET=your_webhook_secret_here

Startup validation:

function validateConfig(): void {
  const key = process.env.LINEAR_API_KEY;
  if (!key) throw new Error("LINEAR_API_KEY is required");
  if (!key.startsWith("lin_api_")) throw new Error("LINEAR_API_KEY has invalid format");
  if (key.length < 30) throw new Error("LINEAR_API_KEY appears truncated");
}
validateConfig();

Step 2: OAuth 2.0 with PKCE

import express from "express";
import crypto from "crypto";

const app = express();

const OAUTH = {
  clientId: process.env.LINEAR_CLIENT_ID!,
  clientSecret: process.env.LINEAR_CLIENT_SECRET!,
  redirectUri: process.env.LINEAR_REDIRECT_URI!,
  scopes: ["read", "write", "issues:create"],
};

// Generate PKCE verifier and challenge
function generatePKCE() {
  const verifier = crypto.randomBytes(32).toString("base64url");
  const challenge = crypto.createHash("sha256").update(verifier).digest("base64url");
  return { verifier, challenge };
}

// Step 1: Redirect to Linear authorization
app.get("/auth/linear", (req, res) => {
  const state = crypto.randomBytes(16).toString("hex");
  const { verifier, challenge } = generatePKCE();

  // Store state + verifier in session
  req.session!.oauthState = state;
  req.session!.codeVerifier = verifier;

  const url = new URL("https://linear.app/oauth/authorize");
  url.searchParams.set("client_id", OAUTH.clientId);
  url.searchParams.set("redirect_uri", OAUTH.redirectUri);
  url.searchParams.set("response_type", "code");
  url.searchParams.set("scope", OAUTH.scopes.join(","));
  url.searchParams.set("state", state);
  url.searchParams.set("code_challenge", challenge);
  url.searchParams.set("code_challenge_method", "S256");

  res.redirect(url.toString());
});

// Step 2: Handle callback — exchange code for tokens
app.get("/auth/linear/callback", async (req, res) => {
  const { code, state } = req.query;

  // CSRF check
  if (state !== req.session!.oauthState) {
    return res.status(400).json({ error: "Invalid state parameter" });
  }

  const response = await fetch("https://api.linear.app/oauth/token", {
    method: "POST",
    headers: { "Content-Type": "application/x-www-form-urlencoded" },
    body: new URLSearchParams({
      grant_type: "authorization_code",
      code: code as string,
      client_id: OAUTH.clientId,
      client_secret: OAUTH.clientSecret,
      redirect_uri: OAUTH.redirectUri,
      code_verifier: req.session!.codeVerifier,
    }),
  });

  const tokens = await response.json();

  // Store tokens securely (encrypt at rest)
  await storeTokens(req.user!.id, {
    accessToken: encrypt(tokens.access_token),
    refreshToken: encrypt(tokens.refresh_token),
    expiresAt: new Date(Date.now() + tokens.expires_in * 1000),
  });

  res.redirect("/dashboard");
});

Step 3: Token Refresh

As of Oct 2025, all new Linear OAuth apps issue refresh tokens. Existing apps must migrate by April 2026.

async function getValidToken(userId: string): Promise<string> {
  const stored = await getStoredTokens(userId);

  // Refresh if expired or expiring within 5 minutes
  if (stored.expiresAt.getTime() - Date.now() < 5 * 60 * 1000) {
    const response = await fetch("https://api.linear.app/oauth/token", {
      method: "POST",
      headers: { "Content-Type": "application/x-www-form-urlencoded" },
      body: new URLSearchParams({
        grant_type: "refresh_token",
        refresh_token: decrypt(stored.refreshToken),
        client_id: process.env.LINEAR_CLIENT_ID!,
        client_secret: process.env.LINEAR_CLIENT_SECRET!,
      }),
    });

    if (!response.ok) throw new Error(`Token refresh failed: ${response.status}`);
    const tokens = await response.json();

    // Linear rotates refresh tokens — always store the new one
    await storeTokens(userId, {
      accessToken: encrypt(tokens.access_token),
      refreshToken: encrypt(tokens.refresh_token),
      expiresAt: new Date(Date.now() + tokens.expires_in * 1000),
    });

    return tokens.access_token;
  }

  return decrypt(stored.accessToken);
}

Step 4: Webhook Signature Verification

Linear signs every webhook with HMAC-SHA256 using the webhook's signing secret. The signature is in the Linear-Signature header.

import crypto from "crypto";

function verifyWebhookSignature(
  rawBody: string,
  signature: string,
  secret: string
): boolean {
  const expected = crypto
    .createHmac("sha256", secret)
    .update(rawBody)
    .digest("hex");

  // Timing-safe comparison to prevent timing attacks
  try {
    return crypto.timingSafeEqual(
      Buffer.from(signature),
      Buffer.from(expected)
    );
  } catch {
    return false; // Length mismatch
  }
}

// Express middleware — must use raw body parser
app.post("/webhooks/linear", express.raw({ type: "*/*" }), (req, res) => {
  const signature = req.headers["linear-signature"] as string;
  const rawBody = req.body.toString();

  if (!verifyWebhookSignature(rawBody, signature, process.env.LINEAR_WEBHOOK_SECRET!)) {
    return res.status(401).json({ error: "Invalid signature" });
  }

  // Verify webhook timestamp (guard against replay attacks)
  const event = JSON.parse(rawBody);
  const age = Date.now() - event.webhookTimestamp;
  if (age > 60000) {
    return res.status(400).json({ error: "Webhook too old" });
  }

  processEvent(event).catch(console.error);
  res.json({ received: true });
});

Step 5: Secret Rotation

// Support multiple keys during rotation period
const apiKeys = [
  process.env.LINEAR_API_KEY_NEW,
  process.env.LINEAR_API_KEY_OLD,
].filter(Boolean) as string[];

async function getWorkingClient(): Promise<LinearClient> {
  for (const apiKey of apiKeys) {
    try {
      const client = new LinearClient({ apiKey });
      await client.viewer; // Verify key works
      return client;
    } catch {
      continue;
    }
  }
  throw new Error("No valid Linear API key found");
}

Security Checklist

  • [ ] API keys in environment variables only (never in code)
  • [ ] .env files in .gitignore
  • [ ] OAuth state parameter validated (CSRF protection)
  • [ ] PKCE used for public/mobile clients
  • [ ] Tokens encrypted at rest in database
  • [ ] Token refresh implemented (mandatory for new apps)
  • [ ] Webhook signatures verified with crypto.timingSafeEqual
  • [ ] Webhook timestamp checked (< 60s age)
  • [ ] HTTPS enforced on all endpoints
  • [ ] Minimal OAuth scopes requested
  • [ ] API keys rotated quarterly

Error Handling

| Error | Cause | Solution | |-------|-------|----------| | Invalid signature | Webhook secret mismatch | Verify LINEAR_WEBHOOK_SECRET in Linear Settings > API > Webhooks | | invalid_grant | Refresh token expired/revoked | Re-initiate full OAuth flow | | Invalid scope | App not authorized for scope | Request only scopes your app needs | | Authentication required | Token expired, refresh failed | Trigger re-authentication |

Examples

Test Webhook Signature Locally

import crypto from "crypto";

const secret = "test-signing-secret";
const payload = JSON.stringify({
  action: "create",
  type: "Issue",
  data: { id: "test", title: "Test" },
  webhookTimestamp: Date.now(),
});
const sig = crypto.createHmac("sha256", secret).update(payload).digest("hex");
console.log(`Signature: ${sig}`);
console.log(`Valid: ${verifyWebhookSignature(payload, sig, secret)}`);

Resources