cosmix/loom

loom-auth

Authentication and authorization patterns including OAuth2, JWT, RBAC/ABAC, session management, API keys, password hashing, and MFA.

First seen May 20, 2026

Installation

$ npx skills add cosmix/loom --skill loom-auth

Similar popular skills

Related neighbors and high-traction skills in the same topics — useful to compare before installing.

Also in this package

Other skills from cosmix/loom · top by installs.

npx skills add cosmix/loom

Browse all from cosmix/loom

More details

Agent compatibility

Declared targets from SKILL.md / docs. Unmarked agents are not listed — the skill may still install via the CLI.

Claude Code Not declared
Cursor Not declared
Codex Not declared
GitHub Copilot Not declared
Windsurf Not declared
Gemini CLI Not declared
Cline Not declared
OpenCode Not declared

Repository health

Stars 53
License LICENSE
Default branch main
Open issues 0
Status Active

Package contents

Files included with this skill beyond the listing page.

  • skill md SKILL.md 14,405 B
  • docs SUMMARY.md 149 B

History

  1. First seen on skills.sh
  2. First recorded snapshot · 6 installs

SKILL.md

Authentication & Authorization

Authentication proves who you are; authorization decides what you may touch. Authorization is where real bugs cluster — broken object-level authorization (IDOR/BOLA) is the #1 API risk (OWASP API Security Top 10). Get object-ownership checks right before polishing token plumbing.

When to Use

Auth is security-critical. Subject designs, token/session lifecycle changes, access-control model changes, and any code handling production credentials to adversarial review against the checklist below. Treat a framework's defaults as untrusted until its documented behavior and configured version are verified.

Scope boundary: this skill is mechanisms. For vuln scanning use loom-security-scan; deep audit loom-security-audit; architecture threats loom-threat-model.

Authorization: IDOR / BOLA (read this first)

Every request that names an object (/orders/123, ?user_id=…, a foreign key in a body) must verify the caller owns or is granted that specific object — authentication ("is logged in") is not authorization. The bug is invisible in tests that only use one account.

// ❌ BOLA: any authenticated user reads any order by guessing the id
app.get("/orders/:id", requireAuth, async (req, res) => {
  const order = await db.orders.findUnique({ where: { id: req.params.id } });
  res.json(order);
});

// ✅ Scope the query by the authenticated principal (and tenant)
app.get("/orders/:id", requireAuth, async (req, res) => {
  const order = await db.orders.findFirst({
    where: { id: req.params.id, userId: req.user.id }, // ownership in the WHERE
  });
  if (!order) return res.status(404).end(); // 404 not 403: don't confirm existence
  res.json(order);
});

Rules: enforce ownership/tenant in the query filter, not a post-fetch if; check on writes and nested/batch operations too; never trust an id/role/tenant from the client body (mass-assignment → privilege escalation); default-deny. In multi-tenant systems scope every query by tenant_id — a missing tenant filter is cross-tenant data leakage.

OAuth2 Flows

Flow Use for Notes
Authorization Code + PKCE Server apps AND SPAs/mobile PKCE now recommended for all clients, not just public ones
Client Credentials Service-to-service No user context; scope tightly
Device Code TVs/CLI with no browser
Implicit, Password (ROPC) Deprecated by OAuth 2.1; do not use
// Authorization Code + PKCE
authUrl.searchParams.set("response_type", "code");
authUrl.searchParams.set("client_id", CLIENT_ID);
authUrl.searchParams.set("redirect_uri", REDIRECT_URI); // MUST match a registered exact URI
authUrl.searchParams.set("scope", "openid profile email");
authUrl.searchParams.set("state", generateSecureState());     // CSRF: verify on callback
authUrl.searchParams.set("code_challenge", challenge);        // SHA-256(verifier), base64url
authUrl.searchParams.set("code_challenge_method", "S256");    // never "plain"
// token exchange (server-side) includes: grant_type=authorization_code, code, code_verifier

⚠ Gotchas: validate state on the callback (CSRF); validate redirecturi against an exact allowlist (open-redirect / code interception); codechallengemethod=S256 only; keep clientsecret server-side; for OIDC validate the ID token's nonce.

JWT

function signToken(payload) {
  return jwt.sign({ ...payload, iat: now() }, PRIVATE_KEY, {
    algorithm: "RS256", expiresIn: "15m",
    issuer: "https://api.example.com", audience: "https://app.example.com",
  });
}
function verifyToken(token) {
  return jwt.verify(token, PUBLIC_KEY, {
    algorithms: ["RS256"],            // PIN the algorithm — never read alg from the token
    issuer: "https://api.example.com",
    audience: "https://app.example.com",
  });
}

JWT footguns (each is a real CVE class)

  • alg: none — a token with no signature. Mitigation: pass an explicit algorithms allowlist to verify; never let the library pick from the header.
  • RS256 → HS256 key confusion — attacker flips alg to HS256 and signs with your public key (which HS256 treats as the HMAC secret). Mitigation: pin algorithms: ["RS256"]; never accept the token's declared alg.
  • Missing claim validation — always verify exp, nbf, iss, and aud. A valid signature only proves who issued it, not for whom — without aud a token minted for service A is replayable at service B.
  • No revocation — JWTs are valid until exp. For logout/ban, keep access tokens short (≤15 min) and gate refresh through a server-side allowlist (below), or maintain a denylist of jti.
  • Sensitive data in payload — JWT is signed, not encrypted; base64 is readable. No PII/secrets in claims.
  • HS256 in distributed systems — every verifier needs the shared secret, so any one service can mint tokens. Prefer RS256/ES256 (asymmetric) so verifiers hold only the public key.

Refresh token rotation (enables revocation)

async function refreshTokens(refreshToken) {
  const stored = await db.refreshTokens.findUnique({ where: { token: hashToken(refreshToken) } });
  if (!stored || stored.expiresAt < new Date()) throw new UnauthorizedError();
  // Reuse detection: if a rotated (deleted) token is presented again, treat as theft →
  // revoke the whole token family for that user.
  await db.refreshTokens.delete({ where: { id: stored.id } }); // one-time use
  const next = generateSecureToken();
  await db.refreshTokens.create({ data: { token: hashToken(next), userId: stored.userId, expiresAt: addDays(new Date(), 7) } });
  return { accessToken: signToken({ sub: stored.userId }), refreshToken: next };
}

Store refresh tokens hashed (they are password-equivalent); short access (≤15 min), refresh ≤7 days; deliver refresh tokens in httpOnly; Secure; SameSite cookies, never in JS-readable storage.

RBAC / ABAC

RBAC = permissions grouped into roles (simple, coarse). ABAC = policy functions over subject/resource/action/environment attributes (fine-grained, dynamic — needed for ownership, department, time-of-day, tenant).

// RBAC middleware — expand roles to permissions, honor ":own" resource scope
function requirePermission(permission) {
  return (req, res, next) => {
    const perms = req.user.roles.flatMap((r) => ROLES[r]);
    const [resource, action, scope] = permission.split(":");
    const ok = perms.includes(permission) ||
      (scope === "own" && perms.includes(`${resource}:${action}`));
    if (!ok) return res.status(403).json({ error: "Forbidden" });
    next(); // NOTE: ":own" still requires a per-object ownership check (see IDOR/BOLA)
  };
}
// ABAC — every policy must pass (default-deny)
const policies = [
  (ctx) => ctx.resource.owner === ctx.subject.id,
  (ctx) => ctx.subject.roles.includes("manager") && ctx.resource.department === ctx.subject.department,
];
const allow = (ctx) => policies.every((p) => p(ctx));

⚠ Middleware-level RBAC checks the endpoint; it does not prove the caller owns the specific object the route resolves to. Function-level auth without object-level auth is still BOLA.

Sessions

async function createSession(userId, req) {
  const sessionId = crypto.randomUUID();          // ≥128 bits entropy from a CSPRNG
  await redis.setex(`session:${sessionId}`, SESSION_TTL,
    JSON.stringify({ userId, createdAt: Date.now(), ip: req.ip, ua: req.headers["user-agent"] }));
  await redis.sadd(`user-sessions:${userId}`, sessionId); // index for bulk invalidation
  return sessionId;
}

Cookie flags: httpOnly; Secure; SameSite=Lax|Strict; Path=/. Regenerate the session ID on privilege change (login, step-up) to kill session fixation. Absolute (24h) + idle (30m) timeouts. Clear ALL sessions on password change/reset. Store session IDs server-side hashed if they double as bearer tokens.

API Keys

function generateApiKey() {
  const key = `sk_live_${crypto.randomBytes(32).toString("base64url")}`;
  return { key, prefix: key.slice(0, 16), hash: sha256(key) }; // show key ONCE; store only the hash
}

Store only the hash (fast SHA-256 is fine here — keys are high-entropy, unlike passwords). Prefix (sklive) enables identification, log redaction, and secret-scanner detection. Attach scopes + per-key rate limit + expiry. Rotate without downtime by allowing N active keys per principal.

Password Hashing

// argon2id — DEFAULT for new systems (memory-hard → resists GPU/ASIC cracking)
await argon2.hash(password, {
  type: argon2.argon2id,
  memoryCost: 65536,  // 64 MiB (OWASP floor 19 MiB; raise until ~0.5–1s/hash on your hardware)
  timeCost: 3,        // iterations
  parallelism: 4,
});
// bcrypt — acceptable legacy; cost ≥ 12
await bcrypt.hash(password, 12);

⚠ Why these and not others:

  • argon2id blends argon2i (side-channel resistance) + argon2d (GPU resistance) — the recommended default. Tune params to your box, not blindly copy.
  • bcrypt silently truncates input at 72 bytes — long passwords / a pepper appended past 72 bytes are ignored. Pre-hash (base64(sha256(pw))) before bcrypt if you must exceed 72 bytes.
  • Never MD5/SHA-1/SHA-256/unsalted for passwords — GPUs do billions/sec; fast hashes are the wrong tool. Salt is built into argon2/bcrypt output; don't roll your own.
  • Verify with the library's verify/compare (constant-time). Never hash(input) == stored.
  • Peppers (an app-side secret added before hashing) belong in a KMS/HSM, not the DB.

MFA

// TOTP: verify with a ±1 step window for clock skew; store the secret encrypted at rest
authenticator.verify({ token, secret }); // otplib default window handles skew

Prefer WebAuthn/passkeys (phishing-resistant, bound to origin) > TOTP > SMS (SIM-swap/interceptable — backup only). Issue 10 single-use backup codes, stored hashed. Require MFA re-verification (step-up) for sensitive actions (password/email change, payouts). Rate-limit TOTP attempts (brute-force is 10⁶ codes).

Timing-Safe Comparison

Comparing secrets with ==/===/strcmp leaks length and prefix via early-exit timing — an attacker measures response time to recover a token/HMAC byte-by-byte. Use constant-time compare for any secret equality: session tokens, API keys, password-reset tokens, HMAC/webhook signatures, MFA codes.

import { timingSafeEqual } from "crypto";
function safeEqual(a: string, b: string): boolean {
  const ba = Buffer.from(a), bb = Buffer.from(b);
  if (ba.length !== bb.length) return false; // length is not secret here; lengths already differ
  return timingSafeEqual(ba, bb);
}

Equivalents: Python hmac.compare_digest, Go crypto/subtle.ConstantTimeCompare, Rust subtle/ring. For hashed values (argon2/bcrypt) the library's verify already handles this.

Security Checklist (verify before done)

Credentials & tokens:

  • Passwords hashed with argon2id (or bcrypt cost ≥12); no fast/unsalted hashes; input ≤72 bytes for bcrypt or pre-hashed
  • Refresh tokens, API keys, MFA secrets, reset tokens stored hashed/encrypted, never plaintext
  • JWT verify pins algorithms; validates exp, nbf, iss, aud; RS256/ES256 (no alg:none, no HS256 in distributed systems)
  • No secrets in JWT claims, logs, URLs, or error messages
  • All secret/token/HMAC comparisons are constant-time

Access control:

  • Every object access checks ownership/tenant in the query filter (no IDOR/BOLA); default-deny
  • Write/batch/nested operations authorized, not just reads
  • Client cannot set id/role/tenant/isAdmin via mass assignment
  • :own-scoped permissions still perform per-object checks

Flows & sessions:

  • OAuth: state verified, redirect_uri allowlisted (exact), PKCE S256
  • Session ID regenerated on login/step-up; httpOnly; Secure; SameSite cookies; absolute + idle timeouts; all sessions cleared on password change
  • Rate limiting + lockout on auth endpoints; identical error for bad-user vs bad-password (no user enumeration); timing between the two paths comparable
  • MFA re-verified for sensitive actions; backup codes hashed & single-use
  • HTTPS/HSTS enforced on all auth endpoints; auth events logged for audit (without secrets)

Complete Login Flow (reference)

async function login(email, password, mfaCode) {
  const user = await db.users.findUnique({ where: { email } });
  // Run a dummy verify even when user is null so timing doesn't reveal account existence.
  const ok = await verifyPasswordArgon2(user?.passwordHash ?? DUMMY_HASH, password);
  if (!user || !ok) {
    if (user) await incrementFailedAttempts(user.id);
    throw new UnauthorizedError("Invalid credentials"); // same message either branch
  }
  if (user.lockedUntil && user.lockedUntil > new Date()) throw new UnauthorizedError("Account locked");
  if (user.mfaEnabled) {
    if (!mfaCode) return { requiresMfa: true, mfaToken: generateMfaToken(user.id) };
    if (!(await verifyMFA(user.id, mfaCode))) throw new UnauthorizedError("Invalid MFA code");
  }
  await db.users.update({ where: { id: user.id }, data: { failedAttempts: 0, lockedUntil: null } });
  return { accessToken: signToken({ sub: user.id, roles: user.roles }), refreshToken: await createRefreshToken(user.id) };
}