Claude-code-plugins-plus-skills linear-security-basics
install
source · Clone the upstream repo
git clone https://github.com/jeremylongshore/claude-code-plugins-plus-skills
Claude Code · Install into ~/.claude/skills/
T=$(mktemp -d) && git clone --depth=1 https://github.com/jeremylongshore/claude-code-plugins-plus-skills "$T" && mkdir -p ~/.claude/skills && cp -r "$T/plugins/saas-packs/linear-pack/skills/linear-security-basics" ~/.claude/skills/jeremylongshore-claude-code-plugins-plus-skills-linear-security-basics && rm -rf "$T"
manifest:
plugins/saas-packs/linear-pack/skills/linear-security-basics/SKILL.mdsource content
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)
-
files in.env.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 |
|---|---|---|
| Webhook secret mismatch | Verify in Linear Settings > API > Webhooks |
| Refresh token expired/revoked | Re-initiate full OAuth flow |
| App not authorized for scope | Request only scopes your app needs |
| 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)}`);