Install
$ agentstack add skill-san-npm-skills-ws-auth-implementation 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 Used
- ✓ 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
Authentication & Authorization
Security-critical patterns for AuthN/AuthZ in 2026. Code here is meant to be copied, so it is written to be correct and safe by default: every secret stored hashed, every token rotation atomic, every redirect-based flow CSRF-protected via state/PKCE. Vendor endpoints and library APIs drift — when a value here is dated, the inline note tells you where to re-verify.
Threat-model defaults: assume the browser is hostile (XSS can read anything JS can), assume tokens leak, assume requests are replayed and races happen. Prefer short-lived access tokens + server-held session/refresh state. For SPAs, prefer a BFF (Backend-for-Frontend) holding tokens server-side over putting access tokens in localStorage.
1. OAuth 2.1 / OIDC Flows
OAuth 2.1 (the consolidation of 2.0 + best-practice RFCs) makes PKCE mandatory for all clients, forbids the implicit and password grants, and requires exact redirect-URI matching. Use the Authorization Code flow + PKCE everywhere (yes, even confidential server-side clients).
Discover endpoints (don't hardcode)
Prefer the provider's OIDC discovery document over hardcoded URLs so endpoints and the JWKS URI stay correct:
// Fetch once at boot, cache in memory (respect Cache-Control)
const discovery = await fetch(
'https://accounts.google.com/.well-known/openid-configuration'
).then(r => r.json());
// => { authorization_endpoint, token_endpoint, jwks_uri, issuer, ... }
// Google (as of Jun 2026): authorization_endpoint = https://accounts.google.com/o/oauth2/v2/auth
// token_endpoint = https://oauth2.googleapis.com/token
// Verify: https://accounts.google.com/.well-known/openid-configuration
Authorization Code + PKCE — full server-side flow with state
This is the canonical flow. state and the PKCE code_verifier are both persisted server-side before redirect and verified on callback — skipping either reopens CSRF / login-injection / code-injection. Below, secrets live on the server and only code_challenge + state ever hit the browser.
import crypto from 'node:crypto';
const b64url = (buf) => buf.toString('base64url'); // Node >=16 supports 'base64url'
function pkcePair() {
const verifier = b64url(crypto.randomBytes(32)); // 43-128 chars, high entropy
const challenge = b64url(crypto.createHash('sha256').update(verifier).digest());
return { verifier, challenge };
}
// --- Step 1: begin login (server route) ---
app.get('/auth/login', async (req, res) => {
const state = b64url(crypto.randomBytes(32));
const nonce = b64url(crypto.randomBytes(32)); // OIDC: binds id_token to this session
const { verifier, challenge } = pkcePair();
// PERSIST state + verifier + nonce server-side, keyed to THIS session, BEFORE redirecting.
// Short TTL; single use. (Express-session shown; a signed httpOnly cookie also works.)
req.session.oauth = { state, nonce, verifier, createdAt: Date.now() };
const url = new URL(discovery.authorization_endpoint);
url.searchParams.set('client_id', process.env.OAUTH_CLIENT_ID);
url.searchParams.set('redirect_uri', process.env.OAUTH_REDIRECT_URI); // must EXACTLY match registered URI
url.searchParams.set('response_type', 'code');
url.searchParams.set('scope', 'openid email profile');
url.searchParams.set('code_challenge', challenge);
url.searchParams.set('code_challenge_method', 'S256');
url.searchParams.set('state', state);
url.searchParams.set('nonce', nonce);
res.redirect(url.toString());
});
// --- Step 2: callback (server route) — VALIDATE everything ---
app.get('/auth/callback', async (req, res) => {
const { code, state } = req.query;
const saved = req.session.oauth;
delete req.session.oauth; // consume immediately so it can't be replayed
// (a) provider returned an error?
if (req.query.error) return res.status(400).send(`OAuth error: ${req.query.error}`);
// (b) we actually started a flow, and it hasn't expired
if (!saved || Date.now() - saved.createdAt > 10 * 60 * 1000) {
return res.status(400).send('No pending OAuth flow / expired');
}
// (c) STATE MUST MATCH — constant-time compare to avoid timing oracles
const ok = typeof state === 'string'
&& state.length === saved.state.length
&& crypto.timingSafeEqual(Buffer.from(state), Buffer.from(saved.state));
if (!ok) return res.status(403).send('Invalid OAuth state'); // CSRF / login-injection blocked here
// (d) need a code
if (typeof code !== 'string' || !code) return res.status(400).send('Missing authorization code');
// (e) exchange code — token request is form-encoded; include the PKCE verifier we persisted
const body = new URLSearchParams({
grant_type: 'authorization_code',
code,
redirect_uri: process.env.OAUTH_REDIRECT_URI,
client_id: process.env.OAUTH_CLIENT_ID,
code_verifier: saved.verifier, // proves we started this exact flow
});
// Confidential clients add their secret (Basic auth header preferred over body params):
const headers = { 'Content-Type': 'application/x-www-form-urlencoded' };
if (process.env.OAUTH_CLIENT_SECRET) {
const basic = Buffer.from(
`${process.env.OAUTH_CLIENT_ID}:${process.env.OAUTH_CLIENT_SECRET}`
).toString('base64');
headers.Authorization = `Basic ${basic}`;
}
const tokenRes = await fetch(discovery.token_endpoint, { method: 'POST', headers, body });
if (!tokenRes.ok) return res.status(401).send('Token exchange failed');
const tokens = await tokenRes.json(); // { access_token, refresh_token, id_token, expires_in }
// (f) Validate the OIDC id_token signature/iss/aud/exp AND that nonce matches saved.nonce
// (see §2 verifyToken; pass audience = OAUTH_CLIENT_ID and check decoded.nonce === saved.nonce)
// (g) Establish your OWN session here (don't hand provider tokens to the browser).
res.redirect('/');
});
Authorization Code + PKCE — public client (SPA/mobile) caveat
A SPA cannot keep state/verifier truly secret from XSS. sessionStorage survives a redirect but is JS-readable. Preferred 2026 pattern: run the code exchange in a BFF so the browser never holds tokens. If you must do it browser-side, still generate and check state, and still send the code_verifier:
// Browser: begin
const { verifier, challenge } = await pkcePairWebCrypto(); // Web Crypto version below
const state = crypto.randomUUID();
sessionStorage.setItem('pkce_verifier', verifier);
sessionStorage.setItem('oauth_state', state);
const url = new URL(discovery.authorization_endpoint);
url.searchParams.set('client_id', CLIENT_ID);
url.searchParams.set('redirect_uri', REDIRECT_URI);
url.searchParams.set('response_type', 'code');
url.searchParams.set('scope', 'openid email profile');
url.searchParams.set('code_challenge', challenge);
url.searchParams.set('code_challenge_method', 'S256');
url.searchParams.set('state', state);
location.href = url.toString();
// Browser: callback — VALIDATE state before exchanging
const params = new URLSearchParams(location.search);
const code = params.get('code');
const returnedState = params.get('state');
const savedState = sessionStorage.getItem('oauth_state');
const verifier = sessionStorage.getItem('pkce_verifier');
sessionStorage.removeItem('oauth_state');
sessionStorage.removeItem('pkce_verifier');
if (!code || !returnedState || returnedState !== savedState) {
throw new Error('Invalid OAuth state or missing code'); // stop — do not exchange
}
const res = await fetch(discovery.token_endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code', code,
client_id: CLIENT_ID, redirect_uri: REDIRECT_URI, code_verifier: verifier,
}),
});
const tokens = await res.json();
// Web Crypto PKCE (browser)
async function pkcePairWebCrypto() {
const bytes = crypto.getRandomValues(new Uint8Array(32));
const verifier = btoa(String.fromCharCode(...bytes))
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier));
const challenge = btoa(String.fromCharCode(...new Uint8Array(digest)))
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
return { verifier, challenge };
}
Client Credentials Flow (Machine-to-Machine)
For backend services and API-to-API calls. No user. Token requests are form-encoded per RFC 6749, the secret stays server-side, and you should cache the token until shortly before expires_in rather than minting one per call.
let cached = { token: null, exp: 0 };
async function getServiceToken() {
if (cached.token && Date.now() callback(err, key?.getPublicKey()));
}
function verifyToken(token) {
return new Promise((resolve, reject) => {
jwt.verify(token, getKey, {
algorithms: ['RS256'], // pin; never allow 'none' or HS*/RS* mixing
issuer: 'https://auth.example.com', // must match token iss
audience: 'https://api.example.com', // must match token aud (your API/client id)
clockTolerance: 5, // seconds, for minor clock skew
}, (err, decoded) => (err ? reject(err) : resolve(decoded)));
});
}
// Express middleware
async function authMiddleware(req, res, next) {
const token = req.headers.authorization?.replace(/^Bearer /, '');
if (!token) return res.status(401).json({ error: 'No token provided' });
try {
req.user = await verifyToken(token);
next();
} catch {
return res.status(401).json({ error: 'Invalid token' });
}
}
jsonwebtoken is the workhorse; for ESM/edge runtimes consider jose (jwtVerify + createRemoteJWKSet), which is promise-native and runs on Web Crypto.
Refresh Token Rotation — hashed, atomic, reuse-detecting
Refresh tokens are long-lived bearer credentials, so treat them like passwords:
- Store only a hash (SHA-256 is fine for a high-entropy random token; you don't need bcrypt/argon2 for 256-bit randomness). The raw token exists only in the client's secure cookie.
- Look up by hash, never by raw value.
- Rotate atomically in a DB transaction with a compare-and-set so two concurrent refreshes can't both succeed.
- Detect reuse: a refresh token is single-use. If a used/rotated token is presented again, treat it as theft and revoke the whole token family.
import crypto from 'node:crypto';
const sha256 = (s) => crypto.createHash('sha256').update(s).digest('hex');
const newOpaqueToken = () => crypto.randomBytes(32).toString('base64url'); // 256-bit, unguessable
// Issue at login: create a family, store HASH, return RAW token to client (httpOnly cookie)
async function issueRefreshToken(userId, meta) {
const raw = newOpaqueToken();
const familyId = crypto.randomUUID();
await db.refreshToken.create({ data: {
tokenHash: sha256(raw), userId, familyId,
used: false, revoked: false,
expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // 30 days
userAgent: meta?.userAgent, ip: meta?.ip, // device/session metadata
}});
return raw; // caller sets it as a Secure; HttpOnly; SameSite cookie (path=/auth/refresh)
}
app.post('/auth/refresh', async (req, res) => {
const raw = req.cookies?.refresh_token; // delivered via httpOnly cookie, not body
if (!raw) return res.status(401).json({ error: 'No refresh token' });
const tokenHash = sha256(raw);
try {
const result = await db.$transaction(async (tx) => {
// Lock the row (Postgres) so concurrent refreshes serialize on it.
const [stored] = await tx.$queryRaw`
SELECT * FROM "RefreshToken" WHERE "tokenHash" = ${tokenHash} FOR UPDATE`;
if (!stored) throw { code: 'INVALID' };
// REUSE DETECTION: a used or revoked token presented again => credential theft.
if (stored.used || stored.revoked) {
await tx.refreshToken.updateMany({
where: { familyId: stored.familyId },
data: { revoked: true }, // nuke the entire family
});
throw { code: 'REUSE' };
}
if (stored.expiresAt true.
const claim = await tx.refreshToken.updateMany({
where: { id: stored.id, used: false },
data: { used: true },
});
if (claim.count !== 1) throw { code: 'RACE' }; // someone else won; reject this one
// Mint the next token in the SAME family and store its hash.
const nextRaw = newOpaqueToken();
await tx.refreshToken.create({ data: {
tokenHash: sha256(nextRaw), userId: stored.userId, familyId: stored.familyId,
used: false, revoked: false,
expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
userAgent: req.get('user-agent'), ip: req.ip,
}});
const accessToken = jwt.sign(
{ sub: stored.userId, role: stored.role },
process.env.JWT_PRIVATE_KEY,
{ algorithm: 'RS256', issuer: 'https://auth.example.com',
audience: 'https://api.example.com', expiresIn: '15m' }
);
return { accessToken, nextRaw };
});
res.cookie('refresh_token', result.nextRaw, {
httpOnly: true, secure: true, sameSite: 'strict', path: '/auth/refresh',
maxAge: 30 * 24 * 60 * 60 * 1000,
});
res.json({ accessToken: result.accessToken });
} catch (e) {
if (e?.code === 'REUSE') {
// Optional: log/audit + force user re-auth on all devices.
return res.status(401).json({ error: 'Token reuse detected; family revoked' });
}
return res.status(401).json({ error: 'Invalid refresh token' });
}
});
> The FOR UPDATE row lock + updateMany(where: { used: false }) compare-and-set is what makes this safe under concurrency. On databases without SELECT ... FOR UPDATE, rely solely on the conditional update's affected-row count (claim.count === 1) as the gate — never on a read-then-write without it.
Token lifetimes:
- Access token: 15 minutes (short-lived, stateless, RS256)
- Refresh token: 30 days max, rotated on every use, hashed at rest
- ID token: ~1 hour (OIDC user info; validate
nonceon login)
3. Session Management
Cookie-Based Sessions (Traditional / BFF)
import session from 'express-session';
import { RedisStore } from 'connect-redis';
import { createClient } from 'redis';
const redisClient = createClient({ url: process.env.REDIS_URL });
await redisClient.connect();
app.set('trust proxy', 1); // required behind a TLS-terminating proxy so secure cookies work
app.use(session({
store: new RedisStore({ client: redisClient }),
secret: process.env.SESSION_SECRET, // rotate via an array: [newSecret, oldSecret]
resave: false,
saveUninitialized: false,
name: '__Host-session', // __Host- prefix: requires Secure, path=/, no Domain
cookie: {
secure: true, // HTTPS only
httpOnly: true, // no JS access (XSS can't read it)
sameSite: 'lax', // mitigates cross-site POST CSRF (not a complete defense — see §10)
maxAge: 24 * 60 * 60 * 1000,
path: '/',
// NOTE: __Host- forbids `domain`. Drop the prefix if you need a shared parent domain.
},
}));
// Regenerate the session ID on privilege change to prevent session fixation:
app.post('/auth/login', loginLimiter, async (req, res) => {
// ... verify credentials ...
req.session.regenerate((err) => {
if (err) return res.status(500).end();
req.session.userId = user.id;
res.json({ ok: true });
});
});
Cookie vs Token Comparison
| Aspect | Cookie Sessions | JWT Access Tokens | |--------|----------------|-------------------| | Storage | Server (Redis/DB) | Client — see XSS row | | Stateless | No (server lookup) | Yes (self-contained) | | Revocation | Easy (delete from store) | Hard (need blocklist or short TT
…
Source & license
This open-source skill is cataloged on AgentStack and links to its original source — we do not rehost the code.
- Author: san-npm
- Source: san-npm/skills-ws
- License: MIT
- Homepage: https://skills-ws.vercel.app
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.