Install
$ agentstack add skill-marvinrichter-clarc-auth-patterns Open-source listing — not yet scanned by AgentStack. Follow the source repository for install instructions.
Security review
⚠ Flagged1 finding(s); flagged for manual review. · v0.1.0 How review works →
- • Prompt-injection patterns
- • Secret / credential exfiltration
- • Dangerous shell & filesystem operations
- • Untrusted network calls
- • Known-malicious package signatures
- high Reads credentials/environment and may exfiltrate them.
What it can access
- ✓ Network access No
- ✓ Filesystem access No
- ✓ Shell / process execution No
- ● Environment & secrets Used
- ✓ Dynamic code execution No
From automated source analysis of v0.1.0. “Used” means the capability is present in the source — more access means more to trust, not that it’s unsafe.
About
Auth Patterns Skill
Authentication (who are you?) and authorization (what can you do?) are where most security vulnerabilities live. This skill covers correct implementation — not just review.
When to Activate
- Implementing login / logout flows
- Choosing between JWT and server-side sessions
- Adding OAuth2 / social login (GitHub, Google, etc.)
- Implementing API key authentication (server-to-server)
- Adding role-based access control (RBAC)
- Implementing refresh token rotation
- Adding MFA (multi-factor authentication)
- Auditing an existing auth flow for security gaps or token leakage
Choosing the Right Auth Mechanism
@startuml
start
if (Server-to-server API?) then (yes)
:API Keys\n(X-API-Key header);
stop
else (no)
if (Third-party login\n(GitHub, Google)?) then (yes)
:OAuth2 / OIDC\n(Authorization Code + PKCE);
stop
else (no)
if (Stateless / horizontal scale\nor mobile app?) then (yes)
:JWT Access Token\n+ Refresh Token Rotation\n(store in httpOnly cookie);
stop
else (no)
:Server-Side Sessions\n(Redis-backed, httpOnly cookie);
note right
Simpler, more secure
for traditional web apps
end note
stop
endif
endif
endif
@enduml
Pattern 1: Session-Based Auth (recommended for web apps)
Simpler, more secure than JWT for traditional web applications. The server holds state; tokens can be immediately invalidated.
TypeScript (Express + Redis)
import session from 'express-session';
import RedisStore from 'connect-redis';
import { createClient } from 'redis';
import bcrypt from 'bcrypt';
const redis = createClient({ url: process.env.REDIS_URL });
await redis.connect();
app.use(session({
store: new RedisStore({ client: redis }),
secret: process.env.SESSION_SECRET, // min 32 random bytes
name: '__Host-sid', // __Host- prefix = Secure + no Domain = phishing protection
resave: false,
saveUninitialized: false,
cookie: {
httpOnly: true, // JS cannot read it (XSS protection)
secure: true, // HTTPS only
sameSite: 'lax', // CSRF protection
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
},
}));
// Login
app.post('/api/v1/auth/login', async (req, res) => {
const { email, password } = req.body;
const user = await User.findByEmail(email);
if (!user || !(await bcrypt.compare(password, user.passwordHash))) {
// Same response for wrong email AND wrong password (timing-safe)
return res.status(401).json(problem(401, 'Invalid credentials'));
}
req.session.regenerate((err) => { // Prevent session fixation
req.session.userId = user.id;
req.session.role = user.role;
res.status(200).json({ data: { id: user.id, email: user.email } });
});
});
// Auth middleware
export function requireAuth(req, res, next) {
if (!req.session.userId) {
return res.status(401).json(problem(401, 'Authentication required'));
}
next();
}
// Logout
app.post('/api/v1/auth/logout', (req, res) => {
req.session.destroy(() => {
res.clearCookie('__Host-sid');
res.status(204).send();
});
});
Pattern 2: JWT Auth (stateless / mobile)
Use when: mobile apps, microservices needing stateless auth, horizontal scaling without shared session store.
Critical rules:
- Store access token in memory (JS variable), NOT localStorage (XSS risk)
- Store refresh token in httpOnly cookie
- Short-lived access tokens (15 min), long-lived refresh tokens (7 days)
- Rotate refresh tokens on every use (detect theft)
import jwt from 'jsonwebtoken';
import { randomBytes } from 'crypto';
const ACCESS_SECRET = process.env.JWT_ACCESS_SECRET;
const REFRESH_SECRET = process.env.JWT_REFRESH_SECRET;
const ACCESS_TTL = '15m';
const REFRESH_TTL = '7d';
function issueTokens(userId: string, role: string) {
const accessToken = jwt.sign({ sub: userId, role }, ACCESS_SECRET, { expiresIn: ACCESS_TTL });
const refreshToken = jwt.sign({ sub: userId, jti: randomBytes(16).toString('hex') }, REFRESH_SECRET, { expiresIn: REFRESH_TTL });
return { accessToken, refreshToken };
}
// Login
app.post('/api/v1/auth/login', async (req, res) => {
const user = await validateCredentials(req.body);
const { accessToken, refreshToken } = issueTokens(user.id, user.role);
// Refresh token: httpOnly cookie
res.cookie('refresh_token', refreshToken, {
httpOnly: true, secure: true, sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60 * 1000,
path: '/api/v1/auth/refresh', // Only sent to refresh endpoint
});
// Access token: response body (client stores in memory)
res.json({ data: { access_token: accessToken, expires_in: 900 } });
});
// Refresh — rotate refresh token on every use
app.post('/api/v1/auth/refresh', async (req, res) => {
const token = req.cookies.refresh_token;
if (!token) return res.status(401).json(problem(401, 'No refresh token'));
try {
const payload = jwt.verify(token, REFRESH_SECRET) as jwt.JwtPayload;
// Check token hasn't been used before (rotation theft detection)
const used = await redis.get(`refresh:used:${payload.jti}`);
if (used) {
// Token reuse detected — invalidate all refresh tokens for user
await redis.set(`refresh:revoked:${payload.sub}`, '1', { EX: 7 * 24 * 3600 });
return res.status(401).json(problem(401, 'Refresh token reuse detected'));
}
await redis.set(`refresh:used:${payload.jti}`, '1', { EX: 7 * 24 * 3600 });
const user = await User.findById(payload.sub);
const { accessToken, refreshToken: newRefresh } = issueTokens(user.id, user.role);
res.cookie('refresh_token', newRefresh, { httpOnly: true, secure: true, sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60 * 1000, path: '/api/v1/auth/refresh' });
res.json({ data: { access_token: accessToken, expires_in: 900 } });
} catch {
res.status(401).json(problem(401, 'Invalid refresh token'));
}
});
// Auth middleware
export function requireAuth(req, res, next) {
const token = req.headers.authorization?.replace('Bearer ', '');
if (!token) return res.status(401).json(problem(401, 'Missing token'));
try {
req.user = jwt.verify(token, ACCESS_SECRET) as jwt.JwtPayload;
next();
} catch {
res.status(401).json(problem(401, 'Invalid token'));
}
}
Pattern 3: OAuth2 / OIDC (Social Login)
// GitHub OAuth2 — Authorization Code flow with state param (CSRF protection)
import { randomBytes } from 'crypto';
app.get('/api/v1/auth/github', (req, res) => {
const state = randomBytes(16).toString('hex');
req.session.oauthState = state; // Store state in session
const params = new URLSearchParams({
client_id: process.env.GITHUB_CLIENT_ID,
redirect_uri: `${process.env.APP_URL}/api/v1/auth/github/callback`,
scope: 'read:user user:email',
state,
});
res.redirect(`https://github.com/login/oauth/authorize?${params}`);
});
app.get('/api/v1/auth/github/callback', async (req, res) => {
const { code, state } = req.query;
// Validate state (CSRF protection)
if (state !== req.session.oauthState) {
return res.status(400).json(problem(400, 'Invalid OAuth state'));
}
delete req.session.oauthState;
// Exchange code for token
const tokenRes = await fetch('https://github.com/login/oauth/access_token', {
method: 'POST',
headers: { 'Accept': 'application/json', 'Content-Type': 'application/json' },
body: JSON.stringify({
client_id: process.env.GITHUB_CLIENT_ID,
client_secret: process.env.GITHUB_CLIENT_SECRET,
code,
}),
});
const { access_token } = await tokenRes.json();
// Fetch user profile
const profileRes = await fetch('https://api.github.com/user', {
headers: { Authorization: `Bearer ${access_token}`, 'User-Agent': 'MyApp' },
});
const profile = await profileRes.json();
// Find or create user — match by GitHub ID, not email (emails can change)
let user = await User.findByGithubId(profile.id);
if (!user) {
user = await User.create({
githubId: profile.id,
name: profile.name ?? profile.login,
avatarUrl: profile.avatar_url,
});
}
req.session.regenerate(() => {
req.session.userId = user.id;
res.redirect(process.env.APP_URL + '/dashboard');
});
});
Pattern 4: API Keys (Server-to-Server)
import { createHash, timingSafeEqual } from 'crypto';
// Generate: store prefix (for display) + hash (for verification)
async function createApiKey(userId: string) {
const key = `sk_live_${randomBytes(32).toString('base64url')}`;
const keyHash = createHash('sha256').update(key).digest('hex');
const prefix = key.substring(0, 12); // e.g. "sk_live_Abc1"
await db.insert(apiKeys).values({ userId, keyHash, prefix });
return key; // Return the raw key ONCE — never store it
}
// Middleware: constant-time comparison (prevents timing attacks)
export async function apiKeyAuth(req, res, next) {
const key = req.headers['x-api-key'];
if (!key) return res.status(401).json(problem(401, 'Missing API key'));
const keyHash = createHash('sha256').update(key).digest('hex');
const apiKey = await db.query.apiKeys.findFirst({
where: eq(apiKeys.keyHash, keyHash),
});
if (!apiKey) {
// Still do the comparison to prevent timing attacks
timingSafeEqual(Buffer.from(keyHash), Buffer.from(keyHash));
return res.status(401).json(problem(401, 'Invalid API key'));
}
await db.update(apiKeys).set({ lastUsedAt: new Date() }).where(eq(apiKeys.id, apiKey.id));
req.userId = apiKey.userId;
next();
}
Pattern 5: RBAC (Role-Based Access Control)
> This section covers flat role-based permission checks. For full RBAC implementation patterns (attribute-based access, hierarchical roles, policy engines like OPA/Casbin) → see kubernetes-patterns for K8s RBAC, or consider a dedicated rbac-patterns skill for application-level RBAC.
// Define roles and permissions
const permissions = {
admin: ['users:read', 'users:write', 'orders:read', 'orders:write', 'orders:delete'],
manager: ['orders:read', 'orders:write'],
customer: ['orders:read'],
} as const;
type Permission = typeof permissions[keyof typeof permissions][number];
// Middleware factory
export function requirePermission(permission: Permission) {
return (req, res, next) => {
const userRole = req.session?.role ?? req.user?.role;
const allowed = permissions[userRole] ?? [];
if (!allowed.includes(permission)) {
return res.status(403).json(problem(403, 'Insufficient permissions'));
}
next();
};
}
// Usage
app.delete('/api/v1/orders/:id',
requireAuth,
requirePermission('orders:delete'),
deleteOrderHandler,
);
Anti-Patterns
Hashing Passwords with SHA-256 or MD5
Wrong:
import { createHash } from 'crypto';
// SHA-256 is fast — attackers can brute-force billions of guesses per second
const passwordHash = createHash('sha256').update(password).digest('hex');
Correct:
import bcrypt from 'bcrypt';
// bcrypt is deliberately slow — cost factor 12 = ~250ms per hash
const passwordHash = await bcrypt.hash(password, 12);
const valid = await bcrypt.compare(password, passwordHash);
Why: Fast hash functions (SHA, MD5) allow offline brute-force attacks to crack passwords in seconds; bcrypt/argon2id are designed to be computationally expensive.
Skipping Session Regeneration After Login
Wrong:
app.post('/api/v1/auth/login', async (req, res) => {
const user = await validateCredentials(req.body);
req.session.userId = user.id; // Reuses the pre-login session ID
res.json({ data: user });
});
Correct:
app.post('/api/v1/auth/login', async (req, res) => {
const user = await validateCredentials(req.body);
req.session.regenerate((err) => { // Issues a new session ID on login
req.session.userId = user.id;
res.json({ data: { id: user.id, email: user.email } });
});
});
Why: Reusing the pre-login session ID enables session fixation attacks, where an attacker plants a known session ID and hijacks the session after the victim logs in.
Matching OAuth Users by Email Instead of Provider ID
Wrong:
// If an attacker registers a GitHub account with the same email, they take over the account
let user = await User.findByEmail(profile.email);
if (!user) user = await User.create({ email: profile.email });
Correct:
// Match by the immutable provider-specific ID — email can be changed or shared
let user = await User.findByGithubId(profile.id);
if (!user) user = await User.create({ githubId: profile.id, name: profile.name });
Why: Emails are not unique across providers and can be changed; provider IDs are immutable and scoped to a single identity.
Storing Raw API Keys in the Database
Wrong:
// If the DB is compromised, all API keys are immediately usable
await db.insert(apiKeys).values({ userId, key: rawKey });
Correct:
import { createHash } from 'crypto';
// Store only the SHA-256 hash; return the raw key once to the user
const keyHash = createHash('sha256').update(rawKey).digest('hex');
const prefix = rawKey.substring(0, 12); // For display only
await db.insert(apiKeys).values({ userId, keyHash, prefix });
return rawKey; // Show once — never store
Why: A database breach exposing raw keys gives attackers immediate full access; hashed keys require brute-force that is infeasible for long random keys.
Performing RBAC Checks Only on the Frontend
Wrong:
// React component hides the button, but the API has no authorization check
function AdminPanel() {
if (user.role !== 'admin') return null;
return deleteUser(id)}>Delete User;
}
Correct:
// Every state-changing endpoint enforces permissions server-side
app.delete('/api/v1/users/:id',
requireAuth,
requirePermission('users:write'),
deleteUserHandler,
);
Why: Frontend checks are bypassed by calling the API directly; authorization must be enforced on every server-side request.
Security Hardening Checklist
- [ ] Passwords hashed with bcrypt (cost factor ≥ 12) or argon2id — never SHA/MD5
- [ ] Session secret is 32+ random bytes from
crypto.randomBytes() - [ ]
session.regenerate()called on login (prevent session fixation) - [ ] Refresh tokens rotated on every use, old tokens invalidated
- [ ] OAuth2 state parameter validated (CSRF protection)
- [ ] OAuth2 users matched by provider ID, not email
- [ ] API keys hashed with SHA-256 before storage — raw key never stored
- [ ] API key comparison uses
timingSafeEqual(prevent timing attacks) - [ ] Auth cookies:
httpOnly,secure,sameSiteall set - [ ] RBAC checked server-side on every request, never client-side only
- [ ] Rate limiting on login (5 attempts/15min per IP)
- [ ] Account lockout or CAPTCHA after brute force threshold
- [ ] Error messages identical for wrong email AND wrong password
- [ ] No sensitive data (tokens, passwords) in logs or error responses
Source & license
This open-source skill is cataloged on AgentStack and links to its original source — we do not rehost the code.
- Author: marvinrichter
- Source: marvinrichter/clarc
- License: MIT
- Homepage: https://marvinrichter.github.io/clarc
Install and usage instructions live in the source repository linked above.
Reviews
No reviews yet — be the first.
Write a review
Versions
- v0.1.0 Imported from the upstream source.