Linear security basics
425 plugins, 2,810 skills, 200 agents for Claude Code. Open-source marketplace at tonsofskills.com with the ccpi CLI package manager.
npx -y skills add jeremylongshore/claude-code-plugins-plus-skills --skill linear-security-basicsAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
What its author says it does
Copied from the file, not written here
'Secure API key management, OAuth best practices, and webhook
The file declares its own license as MIT. That is the author’s claim about this one file, and it is not the same thing as the license GitHub reports for the repository, which is listed with the other numbers below.
SKILL.md
9.1 KB, as published. Nobody here has run it
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)
-
.envfiles 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)}`);