Security Concepts

Understanding Open Banking security, authentication methods, and best practices

Introduction

Security is at the heart of Open Banking. This page explains why specific security measures exist, how they work, and best practices for implementing them correctly.

For beginners: Don't skip this section. Understanding security helps you build systems that protect user data and comply with regulations.

For experienced developers: This page explains Open Banking-specific security requirements that may differ from standard OAuth 2.0 implementations.

Why Open Banking Security Matters

Open Banking involves:

  • Access to sensitive financial data
  • Ability to initiate payments
  • Regulatory requirements
  • Consumer trust

One security breach can:

  • Expose customer bank accounts
  • Result in financial loss
  • Violate regulations (fines, license revocation)
  • Destroy user trust

Therefore: Security isn't optional; it's the foundation of everything we build.

Core Security Principles

1. Never Share Passwords

The Problem with Passwords:

  • Users might reuse passwords across services
  • Passwords can be intercepted
  • Password changes break integrations
  • No way to limit access scope

Open Banking Solution:

  • Users authenticate directly with their bank
  • Your application never sees bank credentials
  • Authorisation tokens are scoped and time-limited
  • Access can be revoked without changing passwords

Implementation: Always use OAuth 2.0 authorisation code flow, never ask for bank credentials.

2. Principle of Least Privilege

Request only the minimum permissions needed for your use case.

Bad:

// Requesting everything "just in case"
scope: 'openid accounts:read transactions:read:all transactions:write accounts:write payee:create payment'

Good:

// Only what's needed for a payment
scope: 'openid payment id:hsbc'

Why it matters:

  • Users trust applications that request minimal access
  • Reduces potential damage if tokens are compromised
  • Regulatory requirement

3. Defence in Depth

Use multiple layers of security so a single failure doesn't compromise the entire system.

Layers in Open Banking:

  1. TLS encryption (transport security)
  2. OAuth 2.0 (authorisation framework)
  3. State and nonce (CSRF and replay prevention)
  4. Private key JWT (client authentication)
  5. PKCE (code interception prevention)
  6. Token expiration (time-limited access)

4. Secure Token Storage

Tokens are credentials and must be protected.

Access Tokens:

  • Server-side: Store in memory or secure session storage
  • Client-side: Never store in localStorage
  • Use httpOnly cookies if storing client-side

Refresh Tokens:

  • Server-side only
  • Encrypt at rest
  • Store in secure database
  • Never expose to client-side code

Private Keys:

  • Never commit to version control
  • Use environment variables or secret management services
  • Restrict file permissions (600 on Unix systems)
  • Rotate periodically

Authentication Methods

Why Not Client Secrets?

Traditional OAuth 2.0 often uses client secrets (shared passwords). Open Banking doesn't allow this for production.

Problems with client secrets:

  • Symmetric (same secret on both sides)
  • Can be extracted from applications
  • No proof of possession
  • Vulnerable to interception

Private Key JWT (Required)

Open Banking mandates Private Key JWT authentication.

How it works:

  1. You have a private key (kept secret)
  2. You share the public key with Moneyhub
  3. You sign JWTs with your private key
  4. Moneyhub verifies using your public key

Why it's better:

  • Asymmetric cryptography (private key never shared)
  • Each request is uniquely signed
  • Proof that YOU sent the request
  • Can't be replayed (JTI prevents reuse)

Implementation:

function createClientAssertion(config) {
  const payload = {
    iss: clientId,      // Issuer (you)
    sub: clientId,      // Subject (you)
    aud: tokenEndpoint, // Audience (Moneyhub)
    iat: Math.floor(Date.now() / 1000),  // Issued at
    exp: Math.floor(Date.now() / 1000) + 3600, // Expires
    jti: generateRandomString(32), // Unique ID (prevents reuse)
  };
  
  // Sign with your private key
  return jwt.sign(payload, privateKey, {
    algorithm: 'RS256',
    keyid: keyId,
  });
}

Key points:

  • Each JWT must have a unique jti
  • Expiration should be short (< 1 hour)
  • Use RS256, ES256, or PS256 algorithms
  • Include kid (key ID) in header

Mutual TLS (mTLS)

Some banks require mutual TLS where both client and server authenticate with certificates.

How it works:

  • Traditional TLS: Server proves identity to client
  • mTLS: Both server AND client prove identity

Moneyhub handling: Moneyhub manages mTLS certificates for bank connections, so you don't need to implement this yourself.

Why it exists: Additional layer of security required by some banks and regulators.

Security Parameters

State Parameter

Purpose: Prevent Cross-Site Request Forgery (CSRF) attacks.

The attack:

  1. Attacker creates authorisation request
  2. Tricks victim into clicking link
  3. Victim authorizes attacker's request
  4. Attacker gets access to victim's account

Prevention with state:

// 1. Generate and store state
const state = generateRandomString(32);
req.session.state = state;

// 2. Include in authorization URL
const authUrl = `${baseUrl}/oidc/auth?
  client_id=${clientId}&
  state=${state}&
  ...`;

// 3. Verify when user returns
app.get('/callback', (req, res) => {
  const { state, code } = req.query;
  
  // CRITICAL: Verify state matches
  if (state !== req.session.state) {
    throw new Error('State mismatch - possible CSRF attack');
  }
  
  // State is valid, proceed
  // ...
});

Best practices:

  • Use cryptographically random values
  • Minimum 16 characters
  • Store server-side (session, database)
  • Validate BEFORE using the authorisation code
  • Single-use (delete after validation)

Common mistake: Not validating state or storing it insecurely.

Nonce Parameter

Purpose: Prevent ID token replay attacks.

The attack:

  1. Attacker captures ID token
  2. Attacker replays it in another session
  3. Attacker impersonates user

Prevention with nonce:

// 1. Generate and store nonce
const nonce = generateRandomString(32);
req.session.nonce = nonce;

// 2. Include in authorization request
// (inside request object or claims)

// 3. Verify in ID token
const idToken = jwt.decode(tokens.id_token);

if (idToken.nonce !== req.session.nonce) {
  throw new Error('Nonce mismatch - possible replay attack');
}

Best practices:

  • Similar to state: random, unique, server-side storage
  • Validate before trusting ID token contents
  • Can be skipped when using PKCE with response_type=code (no ID token in URL)

PKCE (Proof Key for Code Exchange)

Purpose: Prevent authorisation code interception.

The attack (without PKCE):

  1. User authorizes on their device
  2. Authorisation code returned via redirect
  3. Attacker intercepts code (malware, network sniffing)
  4. Attacker exchanges code for tokens
  5. Attacker gains access

Prevention with PKCE:

// 1. Generate code verifier (random string 43-128 chars)
const codeVerifier = generateRandomString(64);
localStorage.setItem('code_verifier', codeVerifier);

// 2. Create code challenge (SHA256 hash)
const codeChallenge = base64URLEncode(sha256(codeVerifier));

// 3. Send code challenge in authorization request
const authUrl = `${baseUrl}/oidc/auth?
  code_challenge=${codeChallenge}&
  code_challenge_method=S256&
  ...`;

// 4. Send code verifier when exchanging code
const tokens = await fetch(tokenEndpoint, {
  method: 'POST',
  body: new URLSearchParams({
    code: authorizationCode,
    code_verifier: codeVerifier,
    ...
  }),
});

How it protects:

  • Attacker intercepts code but not verifier
  • Can't exchange code without verifier
  • Server validates: hash(verifier) === challenge

Best practices:

  • Always use PKCE for mobile and SPA apps
  • Use S256 method (SHA256), not "plain"
  • Store verifier securely (browser: sessionStorage, backend: session)

When to use:

  • Always for single-page applications (SPAs)
  • Always for mobile applications
  • Recommended for all applications (even server-side)

Token Security

Access Token Handling

Lifetime: Short (typically 1 hour)

Storage:

  • Server-side: Memory, secure session storage
  • Client-side: Avoid localStorage (vulnerable to XSS)
  • Best: httpOnly cookies

Transmission: Always use HTTPS, include in Authorisation header

Validation:

  • Check expiration before use
  • Handle 401 responses (token expired/invalid)
  • Refresh or re-authenticate as needed

Example:

// Good: Server-side storage
app.post('/api/connect-bank', async (req, res) => {
  const tokens = await moneyhub.exchangeCodeForTokens(...);
  
  // Store in server session
  req.session.accessToken = tokens.access_token;
  req.session.expiresAt = Date.now() + tokens.expires_in * 1000;
  
  res.json({ success: true });
});

// Use stored token
app.get('/api/accounts', async (req, res) => {
  // Check expiration
  if (Date.now() >= req.session.expiresAt) {
    return res.status(401).json({ error: 'Token expired' });
  }
  
  const accounts = await moneyhub.getAccounts({
    token: req.session.accessToken,
  });
  
  res.json(accounts);
});

Bad example:

// DON'T DO THIS: Sending token to client
res.json({
  accessToken: tokens.access_token, // Now exposed to JavaScript
});

// DON'T DO THIS: Storing in localStorage
localStorage.setItem('token', accessToken); // Vulnerable to XSS

Refresh Token Handling

Lifetime: Long (days, weeks, months)

Security concerns: More dangerous than access tokens due to long life

Best practices:

  1. Server-side only: Never send to client-side code
  2. Encrypt at rest: Use database encryption
  3. Rotate on use: Issue new refresh token each time
  4. Revocable: Allow users to revoke
  5. Detect suspicious activity: Unusual location, frequency
  6. Limit scope: Separate refresh tokens per scope

Example:

// Store refresh token securely
async function storeRefreshToken(userId, refreshToken) {
  // Encrypt before storing
  const encrypted = encrypt(refreshToken, ENCRYPTION_KEY);
  
  await db.query(
    'INSERT INTO refresh_tokens (user_id, token, created_at) VALUES (?, ?, NOW())',
    [userId, encrypted]
  );
}

// Use refresh token
async function refreshAccessToken(userId) {
  // Retrieve and decrypt
  const { token: encrypted } = await db.query(
    'SELECT token FROM refresh_tokens WHERE user_id = ?',
    [userId]
  );
  
  const refreshToken = decrypt(encrypted, ENCRYPTION_KEY);
  
  // Exchange for new tokens
  const newTokens = await moneyhub.refreshAccessToken(refreshToken);
  
  // Rotate: Store new refresh token, invalidate old one
  await storeRefreshToken(userId, newTokens.refresh_token);
  await db.query('DELETE FROM refresh_tokens WHERE token = ?', [encrypted]);
  
  return newTokens.access_token;
}

ID Token Validation

Always validate ID tokens before trusting their contents.

Validation steps:

function validateIdToken(idToken, expectedNonce, clientId) {
  // 1. Decode without verification (to get kid)
  const decoded = jwt.decode(idToken, { complete: true });
  
  // 2. Fetch Moneyhub's public key
  const publicKey = await fetchPublicKey(decoded.header.kid);
  
  // 3. Verify signature
  const verified = jwt.verify(idToken, publicKey, {
    algorithms: ['RS256'],
    audience: clientId,
    issuer: 'https://identity.moneyhub.co.uk/oidc',
  });
  
  // 4. Validate nonce
  if (verified.nonce !== expectedNonce) {
    throw new Error('Nonce mismatch');
  }
  
  // 5. Check expiration
  if (verified.exp < Date.now() / 1000) {
    throw new Error('ID token expired');
  }
  
  return verified;
}

What to validate:

  • Signature (using Moneyhub's public key)
  • Audience (aud claim must be your client ID)
  • Issuer (iss must be Moneyhub)
  • Expiration (exp timestamp)
  • Nonce (must match your stored value)

Moneyhub's public keys:

https://identity.moneyhub.co.uk/oidc/.well-known/openid-configuration

Secure Communication

HTTPS/TLS

Always use HTTPS for:

  • All API requests
  • All redirects
  • All webhooks

TLS version: Minimum TLS 1.2

Certificate validation:

  • Don't disable certificate verification
  • Use trusted certificate authorities
  • Keep certificate stores updated

Development exception: Can use HTTP for localhost during development.

Redirect URI Security

Best practices:

  1. Use HTTPS in production:
✅ https://yourapp.com/callback
❌ http://yourapp.com/callback (unless localhost)
  1. Exact matching:
Registered: https://yourapp.com/callback
❌ https://yourapp.com/callback?extra=param (won't match)
❌ https://yourapp.com/callback/ (trailing slash matters)
✅ https://yourapp.com/callback (exact match)
  1. No wildcards:
❌ https://*.yourapp.com/callback
✅ https://api.yourapp.com/callback
✅ https://app.yourapp.com/callback (register both separately)
  1. Avoid open redirects:
// BAD: Vulnerable to open redirect
app.get('/callback', (req, res) => {
  const { code, next } = req.query;
  // ... exchange code ...
  res.redirect(next); // Attacker can set next to malicious site
});

// GOOD: Whitelist allowed redirects
const ALLOWED_REDIRECTS = ['/dashboard', '/accounts'];
app.get('/callback', (req, res) => {
  const { code, next } = req.query;
  // ... exchange code ...
  
  if (ALLOWED_REDIRECTS.includes(next)) {
    res.redirect(next);
  } else {
    res.redirect('/dashboard'); // Default safe redirect
  }
});

Private Key Management

Generation

Use strong keys:

  • RSA: Minimum 2048 bits (4096 recommended)
  • ECDSA: P-256 or higher
  • EdDSA: Ed25519

Use cryptographically secure generators:

# Good: Using openssl
openssl genrsa -out private-key.pem 4096

# Good: Using Moneyhub API client
node ./examples/jwks/create-jwks.js --key-size 4096

Storage

Never:

  • ❌ Commit to version control (Git, SVN)
  • ❌ Include in Docker images
  • ❌ Store in application code
  • ❌ Share via email, Slack, etc.
  • ❌ Store unencrypted in cloud storage

Best practices:

  • ✅ Use environment variables
  • ✅ Use secret management services (AWS Secrets Manager, Azure Key Vault, HashiCorp Vault)
  • ✅ Encrypt at rest
  • ✅ Restrict file permissions (chmod 600)
  • ✅ Separate keys per environment

Example: Environment variables

// Load from environment
const privateKey = process.env.MONEYHUB_PRIVATE_KEY;

// Or from secure file
const fs = require('fs');
const privateKey = fs.readFileSync(process.env.PRIVATE_KEY_PATH, 'utf8');

.gitignore:

# Private keys
*.pem
*.key
private-keys/
.env
secrets/

Rotation

Recommended: Rotate keys periodically (every 1-2 years)

Process:

  1. Generate new key pair
  2. Add new public key to Moneyhub (support multiple keys)
  3. Update application to use new private key
  4. Verify everything works
  5. Remove old public key after transition period
  6. Securely delete old private key

Key Compromise

If private key is compromised:

  1. Immediately: Remove public key from Moneyhub Admin Portal
  2. Generate new key pair
  3. Update configuration
  4. Investigate how compromise occurred
  5. Notify Moneyhub support
  6. Review audit logs for suspicious activity
  7. Consider notifying affected users

Common Security Mistakes

1. Not Validating State

// ❌ BAD: Accepting any state
app.get('/callback', (req, res) => {
  const { code } = req.query;
  // No state validation!
  exchangeCode(code);
});

// ✅ GOOD: Always validate
app.get('/callback', (req, res) => {
  const { code, state } = req.query;
  
  if (state !== req.session.state) {
    throw new Error('Invalid state');
  }
  
  exchangeCode(code);
});

2. Exposing Tokens Client-Side

// ❌ BAD: Sending token to browser
res.json({ accessToken: tokens.access_token });

// ✅ GOOD: Keep server-side
req.session.accessToken = tokens.access_token;
res.json({ success: true });

3. Logging Sensitive Data

// ❌ BAD: Logging tokens
console.log('Received tokens:', tokens);

// ✅ GOOD: Log without sensitive data
console.log('Token exchange successful', {
  userId: tokens.sub,
  expiresIn: tokens.expires_in,
  // Don't log actual token values
});

4. Ignoring Certificate Errors

// ❌ BAD: Disabling certificate validation
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';

// ✅ GOOD: Fix certificate issues properly
// Use valid certificates, don't disable validation

5. Reusing JTIs

// ❌ BAD: Static JTI
const jti = 'my-static-id';

// ✅ GOOD: Unique JTI each time
const jti = crypto.randomBytes(16).toString('hex');

Production Security Checklist

Before going live, verify:

Authentication & Authorisation

  • Using Private Key JWT (not client secrets)
  • Private keys stored securely (not in code/Git)
  • Using RS256, ES256, or PS256 algorithms
  • Each JWT has unique JTI
  • State parameter validated
  • Nonce parameter validated (where applicable)
  • PKCE implemented (for SPAs/mobile)

Tokens

  • Access tokens stored server-side
  • Refresh tokens encrypted at rest
  • Tokens never logged or exposed
  • Token expiration handled
  • Refresh token rotation implemented
  • No tokens in URLs or GET parameters

Communication

  • All requests use HTTPS
  • TLS 1.2 or higher
  • Certificate validation enabled
  • Redirect URIs use HTTPS (except localhost)
  • Redirect URIs exactly match registered values

Permissions

  • Requesting minimum necessary scopes
  • Not requesting unused permissions
  • User informed of what's being requested

Error Handling

  • Errors don't expose sensitive information
  • Failed auth attempts logged
  • Rate limiting implemented
  • Graceful handling of edge cases

Monitoring

  • Logging authentication events
  • Monitoring for suspicious activity
  • Alert on multiple failed attempts
  • Regular security audits

Compliance

  • Privacy policy published
  • Terms of service published
  • GDPR compliance (if applicable)
  • User can revoke consent
  • Data retention policy defined

Incident Response

If you suspect a security incident:

  1. Isolate: Disable affected API client if possible
  2. Investigate: Review logs, identify scope
  3. Notify: Contact Moneyhub support immediately
  4. Rotate: Generate new keys and secrets
  5. Document: Record timeline and actions taken
  6. Review: Update security practices to prevent recurrence

Moneyhub support: [email protected]

Further Reading

Next Steps

Now that you understand security concepts:

→ Try the [Your First Payment](../getting started/your-first-payment) tutorial with security in mind
→ Review OAuth and OIDC Basics for authentication fundamentals
→ Check the Glossary for security-related terms