← Back

API3:2019 Excessive Data Exposure - Code Examples

Each pair below shows a vulnerable handler that serialises the whole model and the secure version that returns an explicit, allow-listed response. The scenario is the same throughout: a User record whose row contains sensitive fields (password_hash, mfa_secret, is_admin, internal_risk_score) that must never reach the client, while the app only needs id, display_name, and avatar_url.

Flask + SQLAlchemy (Python)

Vulnerable

from flask import Flask, jsonify
from models import User

app = Flask(__name__)

@app.route('/api/users/<int:uid>')
def get_user(uid):
    user = User.query.get_or_404(uid)
    # to_dict() dumps EVERY column: password_hash, mfa_secret, is_admin,
    # internal_risk_score, stripe_customer_id, precise_lat/lng ... all leak.
    return jsonify(user.to_dict())

@app.route('/api/users')
def list_users():
    # Even worse: full objects for EVERY user in one response.
    return jsonify([u.to_dict() for u in User.query.all()])

Secure

from flask import Flask, jsonify, abort
from sqlalchemy.orm import load_only
from models import User, db

app = Flask(__name__)

# Explicit allow-list: only these fields can ever leave the server.
def user_public(user):
    return {
        'id': user.id,
        'displayName': user.display_name,
        'avatarUrl': user.avatar_url,
    }

@app.route('/api/users/<int:uid>')
def get_user(uid):
    user = (db.session.query(User)
            .options(load_only(User.id, User.display_name, User.avatar_url))
            .get(uid))            # sensitive columns are never even loaded
    if user is None:
        abort(404)
    return jsonify(user_public(user))

@app.route('/api/users')
def list_users():
    users = (db.session.query(User)
             .options(load_only(User.id, User.display_name, User.avatar_url))
             .all())
    return jsonify([user_public(u) for u in users])   # minimal objects only

Django REST Framework (Python)

Vulnerable

class UserSerializer(serializers.ModelSerializer):
    class Meta:
        model = User
        fields = '__all__'      # deny-list mindset: exposes every column,
                                # including any sensitive field added later

class UserViewSet(viewsets.ModelViewSet):
    queryset = User.objects.all()
    serializer_class = UserSerializer   # /api/users returns full rows

Secure

class UserPublicSerializer(serializers.ModelSerializer):
    displayName = serializers.CharField(source='display_name')
    avatarUrl = serializers.CharField(source='avatar_url')
    class Meta:
        model = User
        fields = ['id', 'displayName', 'avatarUrl']   # explicit allow-list

class UserSelfSerializer(serializers.ModelSerializer):
    class Meta:
        model = User
        fields = ['id', 'display_name', 'avatar_url', 'email', 'phone']
        # own contact info, but NEVER password_hash / mfa_secret / is_admin

class UserViewSet(viewsets.ReadOnlyModelViewSet):
    queryset = User.objects.only('id', 'display_name', 'avatar_url')

    def get_serializer_class(self):
        obj_id = self.kwargs.get('pk')
        if obj_id and str(self.request.user.pk) == str(obj_id):
            return UserSelfSerializer          # self view: a few more fields
        return UserPublicSerializer            # everyone else: minimal

Express (Node.js)

Vulnerable

const express = require('express');
const app = express();

app.get('/api/users/:id', async (req, res) => {
  const user = await db.users.findById(req.params.id);
  res.json(user);                 // whole document: passwordHash, mfaSecret,
                                  // isAdmin, internalRiskScore ... all sent
});

app.get('/api/users', async (req, res) => {
  const users = await db.users.find();
  res.json(users);                // full documents for everyone
});

Secure

const express = require('express');
const { z } = require('zod');
const app = express();

// Strict output contract: unknown keys are rejected, not silently passed.
const UserResponse = z.object({
  id: z.number(),
  displayName: z.string(),
  avatarUrl: z.string().url(),
}).strict();

function toUserDto(u) {
  return { id: u.id, displayName: u.displayName, avatarUrl: u.avatarUrl };
}

app.get('/api/users/:id', async (req, res, next) => {
  try {
    // Projection at the query layer: sensitive fields never loaded.
    const user = await db.users.findById(req.params.id,
      { projection: { id: 1, displayName: 1, avatarUrl: 1 } });
    if (!user) return res.status(404).json({ error: 'Not found' });
    res.json(UserResponse.parse(toUserDto(user)));   // validated on the way out
  } catch (e) { next(e); }
});

app.get('/api/users', async (req, res, next) => {
  try {
    const users = await db.users.find({},
      { projection: { id: 1, displayName: 1, avatarUrl: 1 } });
    res.json(users.map(u => UserResponse.parse(toUserDto(u))));
  } catch (e) { next(e); }
});

Spring Boot (Java)

Vulnerable

@RestController
class UserController {
    @Autowired UserRepository repo;

    @GetMapping("/api/users/{id}")
    public User getUser(@PathVariable Long id) {
        // Returns the JPA entity directly. Jackson serialises every field,
        // including passwordHash, mfaSecret, admin, internalRiskScore.
        return repo.findById(id).orElseThrow();
    }

    @GetMapping("/api/users")
    public List<User> list() { return repo.findAll(); }   // full entities
}

Secure

// Explicit response record - a DTO, deliberately NOT the entity.
public record UserDto(Long id, String displayName, String avatarUrl) {
    public static UserDto from(User u) {
        return new UserDto(u.getId(), u.getDisplayName(), u.getAvatarUrl());
    }
}

@RestController
class UserController {
    @Autowired UserRepository repo;

    @GetMapping("/api/users/{id}")
    public UserDto getUser(@PathVariable Long id) {
        return UserDto.from(repo.findById(id).orElseThrow());  // only 3 fields
    }

    @GetMapping("/api/users")
    public List<UserDto> list() {
        return repo.findAll().stream().map(UserDto::from).toList();
    }
}
// Tip: a projection interface (id, displayName, avatarUrl) lets Spring Data
// fetch only those columns from the database as well.

GraphQL (field-level authorization)

Vulnerable

type User {
  id: ID!
  displayName: String!
  email: String!          # returned to anyone who asks
  passwordHash: String!   # present on the type = an attacker can select it
  mfaSecret: String!
}

Secure

# Secrets are not in the schema at all; sensitive PII is field-authorized.
type User {
  id: ID!
  displayName: String!
  avatarUrl: String!
  email: String            # nullable; resolver enforces authorization
}

# Resolver: only the user themself (or an admin) gets the email.
const resolvers = {
  User: {
    email: (user, _args, ctx) => {
      if (ctx.viewer.id !== user.id && !ctx.viewer.isAdmin)
        throw new ForbiddenError('not authorized for email');
      return user.email;
    },
  },
};

What Changed, and Why

ProblemVulnerableSecure
Field selectionWhole model / __all__ / entity returnedExplicit DTO allow-list of named fields
Data loadingSELECT * / full documentProjection: only returned columns loaded
List endpointsFull objects for every recordSame minimal DTO applied to each item
New fieldsLeak by default (deny-list)Withheld by default (allow-list)
Response contractNone — whatever serialises, shipsStrict schema, unknown keys rejected
GraphQLSecrets on the type, client picksSecrets off-schema; PII field-authorized

Next Steps