engineering/api-design/skills/api-authentication/SKILL.md
This skill should be used when the user asks about "API authentication", "API authorization", "JWT", "JSON Web Token", "access token", "refresh token", "token rotation", "OAuth 2.0", "OAuth flow", "authorization code flow", "client credentials", "PKCE", "API key", "Bearer token", "session authentication", "cookie authentication", "httpOnly cookie", "CSRF", "CORS with credentials", "token expiry", "token refresh", "invalidating tokens", "logout", "revocation", "stateless auth", "stateful auth", "auth middleware", "validate JWT", "sign JWT", "decode JWT". Also trigger for "how do I protect my API routes", "how do I implement login", "should I use JWT or sessions", "how to handle token expiry", or "users being logged out too often".
npx skillsauth add harsh040506/claude-code-unified-skill-plugin-library api-authenticationInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Production-ready patterns for authenticating and authorizing API consumers.
Authentication (AuthN) — Who are you?
→ Verify identity using a credential (password, token, certificate)
→ "You are [email protected]"
Authorization (AuthZ) — What can you do?
→ Enforce permissions for the verified identity
→ "alice can read orders but not refund them"
Both must be present. Authentication without authorization is incomplete.
| Use case | Recommended | Why | |----------|-------------|-----| | Browser SPA + first-party API | HttpOnly cookies | XSS-safe; CSRF mitigable | | Mobile app + first-party API | JWT in secure storage | No cookie jar on mobile | | Server-to-server (machine) | OAuth 2.0 Client Credentials | No user involved | | Third-party API consumers | API keys | Simple, revocable, auditable | | "Login with Google/GitHub" | OAuth 2.0 Authorization Code + PKCE | Delegates identity to provider | | Legacy / simple internal | Session + server-side store | Simple, fully revocable |
header.payload.signature
eyJhbGciOiJIUzI1NiJ9 ← Base64({"alg":"HS256","typ":"JWT"})
.
eyJ1c2VyX2lkIjoiYWJjIn0 ← Base64({"user_id":"abc","exp":1234567890,"iat":1234567800})
.
SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c ← HMAC-SHA256(header.payload, secret)
import jwt from 'jsonwebtoken';
const JWT_SECRET = process.env.JWT_SECRET!; // At least 256 bits of entropy
const JWT_ALGORITHM = 'HS256' as const; // Always specify — never trust header's alg
const ACCESS_TOKEN_TTL = 15 * 60; // 15 minutes
const REFRESH_TOKEN_TTL = 7 * 24 * 60 * 60; // 7 days
interface JWTPayload {
sub: string; // Subject (user ID) — standard claim
iat: number; // Issued at — standard claim
exp: number; // Expiry — standard claim
jti: string; // JWT ID — unique per token (enables revocation)
roles: string[]; // Custom claim
}
function signAccessToken(userId: string, roles: string[]): string {
return jwt.sign(
{ sub: userId, roles, jti: crypto.randomUUID() },
JWT_SECRET,
{ algorithm: JWT_ALGORITHM, expiresIn: ACCESS_TOKEN_TTL }
);
}
function verifyToken(token: string): JWTPayload {
// MUST pass algorithms array — prevents algorithm confusion attacks
return jwt.verify(token, JWT_SECRET, {
algorithms: [JWT_ALGORITHM],
}) as JWTPayload;
}
// Middleware
function requireAuth(req: Request, res: Response, next: NextFunction) {
const header = req.headers.authorization;
if (!header?.startsWith('Bearer ')) {
return res.status(401).json({ error: { code: 'MISSING_TOKEN' } });
}
try {
const payload = verifyToken(header.slice(7));
req.user = { id: payload.sub, roles: payload.roles };
next();
} catch (err) {
if (err instanceof jwt.TokenExpiredError) {
return res.status(401).json({ error: { code: 'TOKEN_EXPIRED' } });
}
return res.status(401).json({ error: { code: 'INVALID_TOKEN' } });
}
}
✓ Always specify algorithm in verify() call (prevents alg:none attacks)
✓ Use asymmetric keys (RS256/ES256) when multiple services verify tokens
✓ Keep access token TTL short (15 min). Use refresh tokens for longevity
✓ Include jti (JWT ID) for revocation capability
✓ Never put sensitive data in payload (it's base64, not encrypted)
✓ Validate iss (issuer) and aud (audience) claims for multi-service setups
✗ Never trust the alg field from the token header
✗ Never use alg:none
✗ Never store JWT secret in code — use environment variable
Short access tokens (15 min) + long refresh tokens (7 days) = good security/UX balance.
// Store refresh tokens in database (allows revocation)
interface RefreshToken {
id: string; // The actual token (random, opaque — not a JWT)
userId: string;
expiresAt: Date;
revokedAt: Date | null;
replacedByTokenId: string | null; // Track rotation chain
createdAt: Date;
}
async function refreshAccessToken(refreshToken: string) {
// Look up the refresh token
const storedToken = await db.refreshTokens.findUnique({
where: { id: refreshToken },
});
// Validate
if (!storedToken) throw new UnauthorizedError('INVALID_REFRESH_TOKEN');
if (storedToken.revokedAt) {
// Refresh token reuse detected — revoke entire family (security event)
await db.refreshTokens.revokeFamily(storedToken.userId);
throw new UnauthorizedError('REFRESH_TOKEN_REUSE'); // Possible token theft
}
if (storedToken.expiresAt < new Date()) {
throw new UnauthorizedError('REFRESH_TOKEN_EXPIRED');
}
// Rotate: revoke old, issue new
const newRefreshToken = crypto.randomUUID() + crypto.randomUUID();
await db.$transaction([
db.refreshTokens.update({
where: { id: refreshToken },
data: { revokedAt: new Date(), replacedByTokenId: newRefreshToken },
}),
db.refreshTokens.create({
data: {
id: newRefreshToken,
userId: storedToken.userId,
expiresAt: new Date(Date.now() + REFRESH_TOKEN_TTL * 1000),
},
}),
]);
const user = await db.users.findById(storedToken.userId);
return {
accessToken: signAccessToken(user.id, user.roles),
refreshToken: newRefreshToken,
};
}
Preferred over localStorage for browser applications — immune to XSS token theft.
// Set tokens as HttpOnly cookies on login
res.cookie('access_token', accessToken, {
httpOnly: true, // Not accessible from JavaScript
secure: true, // HTTPS only
sameSite: 'lax', // CSRF protection for most cases
maxAge: ACCESS_TOKEN_TTL * 1000,
path: '/',
});
res.cookie('refresh_token', refreshToken, {
httpOnly: true,
secure: true,
sameSite: 'lax',
maxAge: REFRESH_TOKEN_TTL * 1000,
path: '/api/auth/refresh', // Scope to refresh endpoint only
});
// Read from cookie in middleware
function requireAuth(req: Request, res: Response, next: NextFunction) {
const token = req.cookies.access_token; // parse via cookie-parser
if (!token) return res.status(401).json({ error: { code: 'MISSING_TOKEN' } });
// ... verify as before
}
// Logout — clear both cookies
app.post('/api/auth/logout', requireAuth, async (req, res) => {
await db.refreshTokens.revoke(req.cookies.refresh_token);
res.clearCookie('access_token', { httpOnly: true, secure: true, sameSite: 'lax' });
res.clearCookie('refresh_token', { httpOnly: true, secure: true, sameSite: 'lax', path: '/api/auth/refresh' });
res.status(204).end();
});
CSRF with cookies: Use sameSite: 'lax' for most apps. For sameSite: 'none' (cross-site), add a CSRF token (double-submit cookie or synchronizer token pattern).
For third-party developers, service accounts, or CI/CD integrations.
// Key format: prefix + random bytes (prefix helps with secret scanning)
// Example: "myapp_sk_live_aBcDeFgH1234567890ABCDEF"
function generateApiKey(prefix = 'sk_live'): { key: string; hash: string } {
const random = crypto.randomBytes(32).toString('base64url');
const key = `myapp_${prefix}_${random}`;
// Store hash of the key, never the key itself
const hash = crypto.createHash('sha256').update(key).digest('hex');
return { key, hash };
}
// On creation: return key to user (shown only once), store hash
const { key, hash } = generateApiKey();
await db.apiKeys.create({ data: { keyHash: hash, userId, name, scopes } });
return { apiKey: key }; // Show to user now; never show again
// On verification: hash the presented key and compare
async function verifyApiKey(presentedKey: string) {
const hash = crypto.createHash('sha256').update(presentedKey).digest('hex');
const apiKey = await db.apiKeys.findUnique({
where: { keyHash: hash },
include: { user: true },
});
if (!apiKey || apiKey.revokedAt) throw new UnauthorizedError('INVALID_API_KEY');
// Update last used (async, don't block request)
db.apiKeys.update({ where: { keyHash: hash }, data: { lastUsedAt: new Date() } });
return apiKey;
}
For "Login with Google/GitHub/etc." or exposing your API to third-party apps.
// PKCE (Proof Key for Code Exchange) — required for public clients
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 provider
app.get('/auth/github', (req, res) => {
const { verifier, challenge } = generatePKCE();
const state = crypto.randomBytes(16).toString('hex'); // CSRF prevention
// Store verifier + state in server-side session (not cookie)
req.session.pkceVerifier = verifier;
req.session.oauthState = state;
const params = new URLSearchParams({
client_id: process.env.GITHUB_CLIENT_ID!,
redirect_uri: 'https://app.example.com/auth/github/callback',
scope: 'read:user user:email',
state,
code_challenge: challenge,
code_challenge_method: 'S256',
});
res.redirect(`https://github.com/login/oauth/authorize?${params}`);
});
// Step 2: Handle callback
app.get('/auth/github/callback', async (req, res) => {
const { code, state } = req.query;
// Validate state (CSRF check)
if (state !== req.session.oauthState) {
return res.status(400).json({ error: { code: 'INVALID_STATE' } });
}
// Exchange code for token
const tokenResponse = await fetch('https://github.com/login/oauth/access_token', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({
client_id: process.env.GITHUB_CLIENT_ID!,
client_secret: process.env.GITHUB_CLIENT_SECRET!,
code,
code_verifier: req.session.pkceVerifier, // PKCE verification
}),
});
const { access_token } = await tokenResponse.json();
// Fetch user profile
const profile = await fetchGitHubProfile(access_token);
// Find or create user in your DB
const user = await findOrCreateUser({ email: profile.email, githubId: profile.id });
// Issue your own session/tokens
issueSessionCookies(res, user);
res.redirect('/dashboard');
});
// Define roles and permissions
const PERMISSIONS = {
'orders:read': ['customer', 'support', 'manager', 'admin'],
'orders:write': ['manager', 'admin'],
'orders:refund': ['manager', 'admin'],
'users:read': ['support', 'manager', 'admin'],
'users:write': ['admin'],
} as const;
type Permission = keyof typeof PERMISSIONS;
// Authorization middleware factory
function requirePermission(permission: Permission) {
return (req: Request, res: Response, next: NextFunction) => {
const userRoles = req.user?.roles ?? [];
const allowedRoles = PERMISSIONS[permission];
if (!userRoles.some(role => allowedRoles.includes(role as any))) {
// Log the authorization failure
logger.warn({
user_id: req.user?.id,
required_permission: permission,
user_roles: userRoles,
path: req.path,
}, 'Authorization denied');
return res.status(403).json({
error: { code: 'FORBIDDEN', message: 'Insufficient permissions' }
});
}
next();
};
}
// Usage
app.post('/orders/:id/refund', requireAuth, requirePermission('orders:refund'), refundHandler);
// Always verify ownership, not just authentication
app.get('/orders/:id', requireAuth, async (req, res) => {
const order = await db.orders.findById(req.params.id);
if (!order) return res.status(404).json({ error: { code: 'NOT_FOUND' } });
// Critical: check this user owns this resource
// Admins and support may bypass ownership check
const canView = order.customerId === req.user.id
|| req.user.roles.some(r => ['admin', 'support'].includes(r));
if (!canView) {
// Return 404 not 403 — don't confirm the order exists for other users
return res.status(404).json({ error: { code: 'NOT_FOUND' } });
}
res.json(order);
});
import helmet from 'helmet';
app.use(helmet({
contentSecurityPolicy: false, // Handled separately if API + SPA
crossOriginEmbedderPolicy: false, // API consumers need flexibility
}));
// Explicit security headers
app.use((req, res, next) => {
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('X-Frame-Options', 'DENY');
res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
// HSTS — only for HTTPS
res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
next();
});
// CORS — never use wildcard for authenticated APIs
app.use(cors({
origin: (origin, callback) => {
const allowed = ['https://app.example.com', 'https://admin.example.com'];
if (!origin || allowed.includes(origin)) {
callback(null, true);
} else {
callback(new Error('Not allowed by CORS'));
}
},
credentials: true, // Required for cookie-based auth
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization', 'Idempotency-Key'],
}));
For complete token implementation patterns and OAuth flow diagrams, see:
references/token-patterns.md — JWT schema design, RS256 key rotation, JWKS endpoints, refresh token rotation algorithm, and opaque token patternsreferences/oauth-flows.md — Authorization Code + PKCE, Client Credentials, Device Authorization flows with sequence diagrams and common mistake tablestesting
Performs quality control on single-cell RNA-seq data (.h5ad or .h5 files) using scverse best practices with MAD-based filtering and comprehensive visualizations. Use when users request QC analysis, filtering low-quality cells, assessing data quality, or following scverse/scanpy best practices for single-cell analysis.
tools
Deep learning for single-cell analysis using scvi-tools. This skill should be used when users need (1) data integration and batch correction with scVI/scANVI, (2) ATAC-seq analysis with PeakVI, (3) CITE-seq multi-modal analysis with totalVI, (4) multiome RNA+ATAC analysis with MultiVI, (5) spatial transcriptomics deconvolution with DestVI, (6) label transfer and reference mapping with scANVI/scArches, (7) RNA velocity with veloVI, or (8) any deep learning-based single-cell method. Triggers include mentions of scVI, scANVI, totalVI, PeakVI, MultiVI, DestVI, veloVI, sysVI, scArches, variational autoencoder, VAE, batch correction, data integration, multi-modal, CITE-seq, multiome, reference mapping, latent space.
testing
This skill should be used when scientists need help with research problem selection, project ideation, troubleshooting stuck projects, or strategic scientific decisions. Use this skill when users ask to pitch a new research idea, work through a project problem, evaluate project risks, plan research strategy, navigate decision trees, or get help choosing what scientific problem to work on. Typical requests include "I have an idea for a project", "I'm stuck on my research", "help me evaluate this project", "what should I work on", or "I need strategic advice about my research".
development
Run nf-core bioinformatics pipelines (rnaseq, sarek, atacseq) on sequencing data. Use when analyzing RNA-seq, WGS/WES, or ATAC-seq data—either local FASTQs or public datasets from GEO/SRA. Triggers on nf-core, Nextflow, FASTQ analysis, variant calling, gene expression, differential expression, GEO reanalysis, GSE/GSM/SRR accessions, or samplesheet creation.