TL;DR
- A BIN (Bank Identification Number) is the first 6 to 8 digits of any payment card and tells you the issuing bank, card type, country of origin, and whether the card is prepaid or credit.
- You will build a reusable Express middleware function that intercepts incoming card data, calls a BIN lookup API, and attaches structured metadata to
req.binInfo. - The final middleware lets you block prepaid cards, filter by country, flag high-risk issuers, and add card branding in one clean layer before your payment logic runs.
- The API requires two credentials stored in
.env: your API key and your User ID. Both are sent as request headers on every lookup call. - BIN values sent to the API must be between 6 and 8 digits. Fewer than 6 cannot identify an issuer. More than 8 starts encoding the cardholder account number, which you must never send to a third-party service.
- Stack: Node.js 18+, Express 4, Axios, dotenv.
- Total build time: under 60 minutes if you follow every step.
Why BIN Lookup Middleware Is Worth Your Time
If you process payments, subscriptions, or any kind of card-based transaction, you already know that not all cards are created equal.
A prepaid card from a high-risk country behaves very differently from a corporate card issued by a bank in a regulated market. Your fraud rules should know the difference before a charge attempt ever reaches Stripe, Braintree, or your processor of choice.
The cleanest place to handle that logic is middleware. You run the BIN check once, attach the result to the request object, and every subsequent handler in your chain can read from req.binInfo without making a second API call. Your fraud logic, logging layer, and analytics all pull from the same enriched source.
This is a pattern used in production payment platforms. It is not complicated once you see it laid out step by step.
Let us build it.
Prerequisites
Before you write a single line of code, make sure your environment is ready.
Node.js version: 18 or higher. Run node -v to check. Node 18 ships with the native fetch API, but we will use Axios in this tutorial because it gives you cleaner error handling and automatic JSON parsing.
Package manager: npm or yarn. Either works.
Packages you will install:
-
express(version 4.x) for the HTTP server and routing -
axiosfor HTTP requests to the BIN API -
dotenvfor managing your API credentials as environment variables
A BIN lookup API key and User ID: This tutorial uses binsearchlookup.com for the lookup calls. Sign up and grab both your API key and your User ID from the account dashboard. You will need both in Step 2.
A tool for sending test requests: curl, Postman, or the VS Code REST Client extension all work.
Step 1: Set Up a Minimal Express Server
Create a new project folder and initialize it.
mkdir bin-middleware-demo
cd bin-middleware-demo
npm init -y
npm install express axios dotenv
Create your entry file and the middleware directory:
touch index.js
touch .env
mkdir middleware
touch middleware/binLookup.js
Open index.js and write the skeleton server:
// index.js
// Load environment variables from .env before anything else
require("dotenv").config();
const express = require("express");
const app = express();
// Parse incoming JSON bodies
app.use(express.json());
const binLookupMiddleware = require("./middleware/binLookup");
// Apply the BIN middleware only to the validate-card route
app.post("/validate-card", binLookupMiddleware, (req, res) => {
// At this point req.binInfo is already populated by the middleware
return res.json({
success: true,
binInfo: req.binInfo,
});
});
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
Run node index.js and confirm the server starts. You should see the log line in your terminal. Move on to credentials next.
Step 2: Store Both Credentials Safely
The BIN lookup API requires two headers on every request: your API key and your User ID. Both live in your .env file so they never appear hardcoded in source code.
Open .env and add your credentials using the exact variable names shown below:
# .env
# Your API key from your binsearchlookup.com dashboard
X-API-Key=your_api_key_here
# Your User ID from your binsearchlookup.com dashboard
X-User-ID=your_user_id_here
PORT=3000
Replace your_api_key_here and your_user_id_here with the actual values from your dashboard. Never paste real credentials into tutorials, blog posts, or any file that goes into version control.
Add .env to .gitignore right now:
echo ".env" >> .gitignore
This one habit prevents credential leaks. Your code reads the values at runtime using process.env["X-API-Key"] and process.env["X-User-ID"]. No hardcoded strings, ever.
Why two credentials? The API key authenticates your application. The User ID identifies your specific account so the provider can apply per-account rate limits, usage tracking, and billing. Both are required on every request. A call missing either one will be rejected.
Step 3: Extract the BIN From the Request
The BIN is the first 6 to 8 digits of a card number. In real integrations you often receive the full card number on a server-to-server call, or a tokenized version that still carries the BIN prefix.
For this tutorial the client sends a JSON body like this:
{
"cardNumber": "5184369287452458"
}
Your middleware needs to extract the first 8 digits, then validate that the result falls within the required 6-to-8 digit range. Fewer than 6 digits cannot identify a unique issuer. More than 8 digits starts encoding the individual account number, which is sensitive cardholder data you must never send to any third-party API.
Open middleware/binLookup.js and write the extraction logic:
// middleware/binLookup.js
const axios = require("axios");
// BIN must be between 6 and 8 digits -- no more, no less.
// Fewer than 6 cannot identify a unique issuer.
// More than 8 encodes the account number, which is sensitive cardholder data.
function extractBin(cardNumber) {
if (!cardNumber || typeof cardNumber !== "string") return null;
// Remove any spaces or dashes the client might have included
const cleaned = cardNumber.replace(/\D/g, "");
// Take the first 8 digits at most
const bin = cleaned.substring(0, 8);
// Reject if we cannot produce at least 6 digits
if (bin.length < 6) return null;
return bin;
}
module.exports = async function binLookupMiddleware(req, res, next) {
const { cardNumber } = req.body;
const bin = extractBin(cardNumber);
// Strict range check: the BIN must be 6, 7, or 8 digits exactly
if (!bin || bin.length < 6 || bin.length > 8) {
return res.status(400).json({
success: false,
error: "BIN must be between 6 and 8 digits. Please provide a valid card number.",
});
}
// Attach the raw BIN to req for reference downstream
req.bin = bin;
// The API call happens in the next step
next();
};
Notice that the middleware calls next() at the end. That is the Express convention. When you are done with your work, you hand control to the next handler in the chain. If you need to stop the request early, like the 400 response above, you return the response directly without calling next().
Step 4: Call the BIN Lookup API
Now replace the placeholder next() with the actual API request. You will call the BIN lookup service, receive a structured response, and attach it to req.binInfo.
The API accepts your BIN as a query parameter and expects both credential headers on every call. Here is what a real successful response looks like for BIN 518436:
{
"bin": "518436",
"success": true,
"data": {
"BIN": "518436",
"validity": true,
"Type": "CREDIT",
"Brand": "MASTERCARD",
"prepaid": false,
"Category": "TITANIUM",
"Issuer": "ABU DHABI ISLAMIC BANK P.J.S.C.",
"IssuerPhone": "",
"IssuerUrl": "",
"pan_length": 16,
"isoCode2": "AE",
"isoCode3": "ARE",
"country": "United Arab Emirates",
"responseTime": 6
},
"statusCode": 200,
"responseTime": 6
}
Notice the structure. The useful fields live inside data: card type, brand, prepaid flag, issuer name, and country codes. You will reference req.binInfo.data in your business rules.
Now write the full middleware. Replace the entire contents of middleware/binLookup.js:
// middleware/binLookup.js -- full version with API call
const axios = require("axios");
// BIN must be exactly 6 to 8 digits.
// Anything shorter cannot identify an issuer.
// Anything longer encodes the account number -- never send that to a third party.
function extractBin(cardNumber) {
if (!cardNumber || typeof cardNumber !== "string") return null;
const cleaned = cardNumber.replace(/\D/g, "");
const bin = cleaned.substring(0, 8);
if (bin.length < 6) return null;
return bin;
}
module.exports = async function binLookupMiddleware(req, res, next) {
const { cardNumber } = req.body;
const bin = extractBin(cardNumber);
if (!bin || bin.length < 6 || bin.length > 8) {
return res.status(400).json({
success: false,
error: "BIN must be between 6 and 8 digits. Please provide a valid card number.",
});
}
req.bin = bin;
try {
const response = await axios.get(
"https://api.binsearchlookup.com/lookup",
{
params: {
// Pass the extracted BIN as a query parameter
// The API accepts 6, 7, or 8 digits
bin: bin,
},
headers: {
// Both headers are required on every request
// Both values are read from environment variables -- never hardcode them
"X-API-Key": process.env["X-API-Key"],
"X-User-ID": process.env["X-User-ID"],
},
// Fail fast: if the BIN API takes longer than 3 seconds, soft-fail
timeout: 3000,
}
);
// Attach the full enriched BIN response to the request object.
// Every downstream handler can now read req.binInfo without a second API call.
req.binInfo = response.data;
next();
} catch (error) {
// Log the failure internally -- never expose credentials or internals to the client
console.error("BIN lookup failed:", error.message);
// Soft-fail: set binInfo to null and continue.
// A BIN API outage should not take your entire checkout flow down.
// Your route handler decides what to do when binInfo is null.
req.binInfo = null;
next();
}
};
Your middleware now handles both credentials from .env, enforces the BIN digit range, and attaches everything downstream handlers need.
Step 5: Use the Metadata for Real Business Rules
The middleware has done its job. req.binInfo is populated with the full API response. The actual card data lives inside req.binInfo.data.
Open index.js and replace the skeleton route handler with real policy logic:
// index.js -- full version with business rules
require("dotenv").config();
const express = require("express");
const app = express();
app.use(express.json());
const binLookupMiddleware = require("./middleware/binLookup");
// Cards issued outside this list will be blocked
const ALLOWED_COUNTRIES = ["US", "CA", "GB", "AU", "DE", "FR"];
app.post("/validate-card", binLookupMiddleware, (req, res) => {
const binInfo = req.binInfo;
// BIN lookup failed (API outage or unknown BIN) -- block and ask to retry
if (!binInfo || !binInfo.success) {
return res.status(422).json({
success: false,
error: "Unable to verify card origin. Please try again.",
});
}
const cardData = binInfo.data;
// Rule 1: Block prepaid cards.
// Prepaid cards are high-risk for chargebacks and subscription fraud.
if (cardData.prepaid === true) {
return res.status(403).json({
success: false,
error: "Prepaid cards are not accepted for this service.",
code: "PREPAID_CARD_BLOCKED",
});
}
// Rule 2: Block cards issued outside your allowed countries.
// cardData.isoCode2 is the ISO 3166-1 two-letter country code.
const issuerCountry = cardData.isoCode2;
if (issuerCountry && !ALLOWED_COUNTRIES.includes(issuerCountry)) {
return res.status(403).json({
success: false,
error: "Cards from this region are not supported.",
code: "COUNTRY_NOT_ALLOWED",
country: issuerCountry,
countryName: cardData.country,
});
}
// All checks passed. Return enriched card metadata to the caller.
return res.json({
success: true,
bin: req.bin,
cardBrand: cardData.Brand,
cardType: cardData.Type,
cardCategory: cardData.Category,
issuer: cardData.Issuer,
country: issuerCountry,
countryName: cardData.country,
prepaid: cardData.prepaid,
panLength: cardData.pan_length,
message: "Card validated successfully.",
});
});
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
Each rule is a single conditional block. You can add, remove, or reorder rules without touching the middleware at all. The middleware fetches and attaches. The route handler enforces policy. Two different responsibilities, two different places.
Step 6: Test Every Scenario With Curl
Start your server:
node index.js
Test 1: A valid Mastercard Titanium credit card
curl -X POST http://localhost:3000/validate-card \
-H "Content-Type: application/json" \
-d '{"cardNumber": "5184369287452458"}'
Expected response:
{
"bin": "518436",
"success": true,
"data": {
"BIN": "518436",
"validity": true,
"Type": "CREDIT",
"Brand": "MASTERCARD",
"prepaid": false,
"Category": "TITANIUM",
"Issuer": "ABU DHABI ISLAMIC BANK P.J.S.C.",
"IssuerPhone": "",
"IssuerUrl": "",
"pan_length": 16,
"isoCode2": "AE",
"isoCode3": "ARE",
"country": "United Arab Emirates",
"responseTime": 6
},
"statusCode": 200,
"responseTime": 6
}
Note that AE is not in the ALLOWED_COUNTRIES list, so this specific BIN will trigger the country block rule. That is intentional. It lets you confirm that Rule 2 works correctly. To test a passing card, use a BIN issued in the US, CA, GB, AU, DE, or FR.
Test 2: A prepaid card
curl -X POST http://localhost:3000/validate-card \
-H "Content-Type: application/json" \
-d '{"cardNumber": "4023601234567890"}'
Expected: 403 Forbidden with PREPAID_CARD_BLOCKED code.
Test 3: Missing card number
curl -X POST http://localhost:3000/validate-card \
-H "Content-Type: application/json" \
-d '{}'
Expected: 400 Bad Request with a BIN digit range error.
Test 4: Card number too short to produce a valid BIN
curl -X POST http://localhost:3000/validate-card \
-H "Content-Type: application/json" \
-d '{"cardNumber": "411"}'
Expected: 400 Bad Request. The extracted BIN is only 3 digits, which is below the required minimum of 6.
Test 5: Card issued in a blocked country
Use a BIN from an issuer outside your ALLOWED_COUNTRIES list.
Expected: 403 Forbidden with COUNTRY_NOT_ALLOWED, the alpha2 code, and the full country name in the response body.
Run all five tests before moving on. If any response is unexpected, re-read the middleware and double-check your .env file.
Step 7: Add a Simple In-Memory Cache (Optional but Recommended)
You do not need to call the BIN API on every single request. A BIN is permanently tied to an issuer. It does not change. If the same BIN appears twice in a session or across any requests, serve it from cache.
Here is a lightweight in-memory cache using a plain JavaScript Map:
// middleware/binLookup.js -- final version with caching
const axios = require("axios");
// Simple in-memory cache: BIN string -> API response object.
// In production, replace this with Redis and set a TTL of 24 hours.
const binCache = new Map();
function extractBin(cardNumber) {
if (!cardNumber || typeof cardNumber !== "string") return null;
const cleaned = cardNumber.replace(/\D/g, "");
const bin = cleaned.substring(0, 8);
if (bin.length < 6) return null;
return bin;
}
module.exports = async function binLookupMiddleware(req, res, next) {
const { cardNumber } = req.body;
const bin = extractBin(cardNumber);
if (!bin || bin.length < 6 || bin.length > 8) {
return res.status(400).json({
success: false,
error: "BIN must be between 6 and 8 digits. Please provide a valid card number.",
});
}
req.bin = bin;
// Serve from cache if available -- avoids a network call entirely
if (binCache.has(bin)) {
req.binInfo = binCache.get(bin);
req.binCacheHit = true; // Useful for metrics and logging
return next();
}
try {
const response = await axios.get(
"https://api.binsearchlookup.com/lookup",
{
params: { bin: bin },
headers: {
"X-API-Key": process.env["X-API-Key"],
"X-User-ID": process.env["X-User-ID"],
},
timeout: 3000,
}
);
// Store result in cache before attaching to req
binCache.set(bin, response.data);
req.binInfo = response.data;
req.binCacheHit = false;
next();
} catch (error) {
console.error("BIN lookup failed:", error.message);
req.binInfo = null;
req.binCacheHit = false;
next();
}
};
For a production system, replace the Map with a Redis client and set a TTL of 24 hours. BIN data is stable, and Redis handles memory management, persistence, and expiry automatically.
Step 8: Structure Your Project for the Long Term
Here is the final folder structure you should have:
bin-middleware-demo/
middleware/
binLookup.js # Fetches BIN data and attaches it to req.binInfo
index.js # Express server, route definitions, and business rules
.env # API credentials and config (never commit this)
.gitignore
package.json
As your project grows, split the business rules into a separate validators/cardPolicy.js file and import it into the route handler. The middleware stays thin and focused on fetching. The policy layer stays focused on rules. Clean separation means easier unit testing and easier onboarding for every engineer who joins the project after you.
Full Code Reference
Here is every file in its final state so you can copy, paste, and run without scrolling back through the steps.
.env
# .env
# Get both values from your binsearchlookup.com account dashboard.
# Never commit this file. It is already in .gitignore.
X-API-Key=your_api_key_here
X-User-ID=your_user_id_here
PORT=3000
middleware/binLookup.js
const axios = require("axios");
// In-memory cache to avoid redundant API calls for the same BIN.
// Replace with Redis in production and set a 24-hour TTL.
const binCache = new Map();
// BIN must be between 6 and 8 digits.
// 6 digits is the minimum to identify a unique issuer.
// 8 digits is the maximum before you start encoding the account number.
function extractBin(cardNumber) {
if (!cardNumber || typeof cardNumber !== "string") return null;
const cleaned = cardNumber.replace(/\D/g, "");
const bin = cleaned.substring(0, 8);
if (bin.length < 6) return null;
return bin;
}
module.exports = async function binLookupMiddleware(req, res, next) {
const { cardNumber } = req.body;
const bin = extractBin(cardNumber);
if (!bin || bin.length < 6 || bin.length > 8) {
return res.status(400).json({
success: false,
error: "BIN must be between 6 and 8 digits. Please provide a valid card number.",
});
}
req.bin = bin;
// Return cached result if available
if (binCache.has(bin)) {
req.binInfo = binCache.get(bin);
req.binCacheHit = true;
return next();
}
try {
const response = await axios.get(
"https://api.binsearchlookup.com/lookup",
{
params: { bin: bin },
headers: {
// X-API-Key and X-User-ID are both required by the API.
// Both are read from .env -- never hardcode credentials.
"X-API-Key": process.env["X-API-Key"],
"X-User-ID": process.env["X-User-ID"],
},
timeout: 3000,
}
);
binCache.set(bin, response.data);
req.binInfo = response.data;
req.binCacheHit = false;
next();
} catch (error) {
// Soft-fail: log internally, do not expose API details to the client
console.error("BIN lookup failed:", error.message);
req.binInfo = null;
req.binCacheHit = false;
next();
}
};
index.js
require("dotenv").config();
const express = require("express");
const app = express();
app.use(express.json());
const binLookupMiddleware = require("./middleware/binLookup");
// Cards issued outside this list will be blocked.
// Extend this array to match your business requirements.
const ALLOWED_COUNTRIES = ["US", "CA", "GB", "AU", "DE", "FR"];
app.post("/validate-card", binLookupMiddleware, (req, res) => {
const binInfo = req.binInfo;
// BIN lookup failed (API outage or unknown BIN) -- block and ask to retry
if (!binInfo || !binInfo.success) {
return res.status(422).json({
success: false,
error: "Unable to verify card origin. Please try again.",
});
}
const cardData = binInfo.data;
// Rule 1: Block prepaid cards
if (cardData.prepaid === true) {
return res.status(403).json({
success: false,
error: "Prepaid cards are not accepted for this service.",
code: "PREPAID_CARD_BLOCKED",
});
}
// Rule 2: Block cards from unsupported issuer countries
// cardData.isoCode2 holds the ISO 3166-1 alpha-2 country code
const issuerCountry = cardData.isoCode2;
if (issuerCountry && !ALLOWED_COUNTRIES.includes(issuerCountry)) {
return res.status(403).json({
success: false,
error: "Cards from this region are not supported.",
code: "COUNTRY_NOT_ALLOWED",
country: issuerCountry,
countryName: cardData.country,
});
}
// All checks passed
return res.json({
success: true,
bin: req.bin,
cardBrand: cardData.Brand,
cardType: cardData.Type,
cardCategory: cardData.Category,
issuer: cardData.Issuer,
country: issuerCountry,
countryName: cardData.country,
prepaid: cardData.prepaid,
panLength: cardData.pan_length,
cacheHit: req.binCacheHit,
message: "Card validated successfully.",
});
});
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
Summary
You built a production-quality BIN lookup middleware in eight steps. Here is what you now have:
- A reusable
binLookupMiddlewarefunction that extracts the BIN, enforces the strict 6-to-8 digit rule, calls the lookup API with both required credentials, and attaches the full structured response toreq.binInfo. - Both
X-API-KeyandX-User-IDstored safely in.envand passed as headers on every API request. Neither credential ever appears in source code. - Business rules in the route handler that read from
req.binInfo.datato block prepaid cards and cards from unsupported countries. - Graceful soft-fail handling so an API outage does not break your checkout flow.
- An in-memory cache to eliminate redundant network calls for repeated BINs.
- Clean separation between the data-fetching middleware and the business rule layer in the route handler.
The pattern you learned here applies beyond BIN lookups. Any time you need to enrich an incoming request with external data before your main handler runs, middleware is the right tool. IP geolocation, device fingerprinting, user reputation scoring: all of them follow exactly this shape.
FAQ
What is a BIN number and why does it matter for payments?
BIN stands for Bank Identification Number. It is the first 6 to 8 digits of a payment card number and it identifies the issuing bank, the card network (Visa, Mastercard, Amex), the card type (credit, debit, prepaid), and the country where the card was issued. Payment platforms use BIN data to run fraud checks, apply geographic restrictions, and display the correct card brand logo at checkout.
Why does the BIN value sent to the API have to be between 6 and 8 digits?
The BIN lookup database is indexed on 6-digit and 8-digit prefixes. Fewer than 6 digits does not uniquely identify an issuer. More than 8 digits begins encoding the individual account number, which is sensitive cardholder data that you must never send to any third-party service. The extractBin function enforces this range before the network call is ever made.
What are X-API-Key and X-User-ID and why are both required?
X-API-Key authenticates your application to the BIN lookup API. X-User-ID identifies the specific account making the request, which allows the API provider to apply per-account rate limits, usage tracking, and billing. Both are assigned when you create an account at binsearchlookup.com. Storing them in .env with the exact names X-API-Key and X-User-ID and reading them with process.env["X-API-Key"] and process.env["X-User-ID"] keeps them out of your source code and out of version control.
Why use bracket notation like process.env["X-API-Key"] instead of process.env.X_API_KEY?
Dot notation in JavaScript only works for property names that are valid identifiers. The hyphen character in X-API-Key and X-User-ID is not valid in a dot-notation identifier, so you must use bracket notation to access those keys from process.env. Alternatively you can rename the variables in .env to use underscores (for example X_API_KEY) and access them with process.env.X_API_KEY, but keeping the original header names as the variable names makes it immediately obvious which .env value maps to which HTTP header.
Is it safe to log the full card number on the server?
No. You should never log a full card number. Extract only the BIN immediately and discard or tokenize the rest. The BIN alone is not sensitive because it does not identify the cardholder or authorize a transaction. Log the BIN, not the card number.
What happens if the BIN API is down?
The middleware uses a soft-fail pattern. If the API call throws an error or times out, req.binInfo is set to null and next() is called. Your route handler checks for null or !binInfo.success and decides the fallback policy. The example in this tutorial returns a 422 and asks the user to try again. You could also allow the transaction through with a reduced confidence score. The decision belongs in the route handler, not in the middleware.
Can I use this with Stripe or another payment processor?
Yes. This middleware runs before you ever call Stripe. You enrich the request object first, apply your rules, and only then initiate the Stripe payment intent if everything passes. The BIN check adds a validation layer that sits entirely on your own server.
Should I use 6-digit or 8-digit BINs?
Use 8-digit BINs when the card number is long enough to produce them. The payments industry has been migrating toward 8-digit BINs since 2022, and modern BIN lookup APIs return more accurate results with 8 digits. The extractBin function in this tutorial uses substring(0, 8) to grab as many digits as possible up to the 8-digit maximum, then validates that at least 6 were available.
How do I extend the middleware to handle debit cards differently?
Read req.binInfo.data.Type in your route handler. It will be "CREDIT", "DEBIT", or "PREPAID". Write conditional logic for each type without touching the middleware at all. That is the benefit of keeping data fetching and policy enforcement in separate layers.
What is the right timeout for a BIN lookup API call?
A 2 to 3 second timeout is reasonable. BIN lookups are fast because the data is static and well-indexed. If your upstream API consistently takes longer than 1 second, check whether you are hitting a rate limit or whether a geographically closer endpoint is available.
If this tutorial helped you, share what rules you are enforcing in your own BIN middleware in the comments. The more specific your use case, the more others in the community can learn from it.
Top comments (0)