You need Python 3, and for the last step one library. Install Python from
python.org on Windows (tick “Add python.exe to PATH”); macOS
and Linux already have it. Run pip install cryptography once for step 5, and
confirm everything with python3 --version.
Do not use your real exchange account, your real authenticator secret, or your real phone for any of this. The secret below is the example printed in the public RFC that defines these codes, so your output will match this page exactly and nothing you own is exposed. What you are checking is not your account — it is your assumptions about what the second factor actually proves.
Your Exchange Account is a Target
Exchange accounts are high-value targets for attackers. Unlike a self-custody wallet where the attacker needs your seed phrase, an exchange account can potentially be compromised through password theft, SIM swapping, email account takeover, or phishing. Every security feature the exchange offers should be enabled.
Use a Strong, Unique Password
- Use a randomly generated password of at least 16 characters
- This password must be unique — never reused from any other site
- Store it in a reputable password manager (Bitwarden, KeePassXC)
- If the exchange is compromised, a unique password limits the damage to that one account
Enable Two-Factor Authentication (2FA)
2FA is the single most important security feature on your exchange account. Not all 2FA methods are equal:
Attackers call your phone carrier, impersonate you, and transfer your phone number to their SIM card. They then receive your SMS 2FA codes. High-value crypto holders are specifically targeted. Switch to TOTP or hardware keys immediately.
Set Up Anti-Phishing Codes
Many exchanges let you set a personal anti-phishing code — a custom word or phrase that appears in every legitimate email from the exchange. If an email claims to be from the exchange but does not contain your code, it is a phishing attempt.
Enable Withdrawal Address Whitelisting
Withdrawal whitelisting restricts withdrawals to a pre-approved list of addresses:
- Even if an attacker gains access to your account, they cannot withdraw to their own address
- Adding a new address to the whitelist typically requires 2FA confirmation and a 24-48 hour waiting period
- This delay gives you time to detect and respond to unauthorized access
Secure Your Email Account
Most exchange account recovery processes rely on email verification. If your email account is compromised, an attacker can reset your exchange password, bypass 2FA, and withdraw funds. Your email account needs the same level of protection as the exchange account itself.
- Use a strong, unique password for your email account
- Enable 2FA on your email (hardware key preferred)
- Consider using a separate email address exclusively for crypto exchanges
- Disable email forwarding rules — attackers sometimes set forwarding to intercept verification emails silently
Session and Device Management
- Regularly review active sessions and recognized devices in your exchange settings
- Remove any devices or sessions you do not recognize
- Enable login notifications so you are alerted when your account is accessed from a new device or location
- Log out of exchange sessions when not actively trading
API Key Security
If you use API keys for trading bots or portfolio trackers:
- Only grant the minimum permissions needed (read-only for portfolio trackers, no withdrawal permission for trading bots)
- Restrict API keys to specific IP addresses when possible
- Rotate API keys periodically
- Revoke keys immediately if a connected service is compromised
What to Do If Compromised
- Immediately: change your password and revoke all active sessions
- Disable API keys and remove any unrecognized withdrawal addresses
- Contact exchange support to lock the account if funds are being withdrawn
- Check your email for unauthorized forwarding rules or connected applications
- Document everything for potential law enforcement involvement
Now Build Your Own Two-Factor Codes, in Five Steps
Almost everybody with an exchange account has a six-digit code app and almost nobody knows what that code proves. In the next twenty minutes you will write the twelve lines that generate those codes, check them against the official published test values, watch one expire, and then find the gap that makes people lose accounts they thought were protected. The last step shows the one type of second factor that closes it. Every number below came from running these files.
Go: open a terminal in a folder you can write to, e.g.
cd ~/Desktop (Windows: cd %USERPROFILE%\Desktop).
Do: save this as totp.py. It prints nothing on its own
— it is the generator the next steps call.
import hmac, hashlib, struct
def totp(secret: bytes, when: int, step: int = 30, digits: int = 6, algo=hashlib.sha1):
counter = struct.pack(">Q", when // step)
mac = hmac.new(secret, counter, algo).digest()
offset = mac[-1] & 0x0F
code = struct.unpack(">I", mac[offset:offset + 4])[0] & 0x7FFFFFFF
return str(code % 10 ** digits).zfill(digits)
Now save this as check.py and run python3 check.py. It compares
your generator against a value published in RFC 6238, the standard every authenticator app
implements.
import hashlib
from totp import totp
secret = b"12345678901234567890"
print("code at T=59 :", totp(secret, 59, digits=8))
print("RFC 6238 says: 94287082")
print("implementation is correct:", totp(secret, 59, digits=8) == "94287082")
You should see: your own code matching the standard’s:
code at T=59 : 94287082
RFC 6238 says: 94287082
implementation is correct: True
Take in what that means. Your authenticator app is not talking to the exchange, and never has been. It is doing this arithmetic on a shared secret and the clock — which is why it keeps working in aeroplane mode.
If not: False on the last line means a character in the
generator differs — the likely spot is mac[-1] & 0x0F or the
>Q and >I format strings, which must be exactly those.
ModuleNotFoundError: No module named 'totp' means the two files are not in the
same folder.
Go: same folder.
Do: save this as window.py and run it. The starting moment
is fixed so your output matches this page.
from totp import totp
secret = b"12345678901234567890"
start = 1_700_000_000 # a fixed moment, so your output matches this page
previous = None
for offset in range(0, 46, 5):
code = totp(secret, start + offset)
mark = " <-- the code changed here" if previous and code != previous else ""
print(f"second {offset:>2}: {code}{mark}")
previous = code
You should see: the same code repeating, then changing twice:
second 0: 921300
second 5: 921300
second 10: 732303 <-- the code changed here
second 15: 732303
second 20: 732303
second 25: 732303
second 30: 732303
second 35: 732303
second 40: 136087 <-- the code changed here
second 45: 136087
Look closely at where the change happens: at second 10 and second 40, not at 0 and 30. The 30-second windows are aligned to the world clock, not to the moment you opened the app. That is why a code sometimes disappears the instant you finish typing it.
If not: if no <-- marker appears, the
previous comparison is not being updated at the end of the loop. If the codes
differ from those above, start was mistyped — the underscores in
1_700_000_000 are legal Python and are only there for readability.
Go: same folder. This is the number an attacker cares about.
Do: save this as lifetime.py and run it.
from totp import totp
secret = b"12345678901234567890"
for now in (1_700_000_000, 1_700_000_019, 1_700_000_025):
left = 30 - (now % 30)
print(f"at t={now}: code {totp(secret, now)}, valid for {left:>2} more second(s)")
You should see: the same six digits carrying wildly different amounts of remaining life:
at t=1700000000: code 921300, valid for 10 more second(s)
at t=1700000019: code 732303, valid for 21 more second(s)
at t=1700000025: code 732303, valid for 15 more second(s)
Between ten and thirty seconds is plenty. A phishing site does not need to store your code — it needs to use it once, immediately, on the real exchange while you wait for a page that will never load. Most exchanges also accept the previous window to tolerate clock drift, which quietly doubles that budget.
If not: if every line shows 30 seconds remaining, the modulo was written
as now % 30 - 30 instead of 30 - now % 30. A negative number means
the same thing with the operands swapped.
Go: same folder. This step is short because the finding is the point.
Do: save this as whichsite.py and run it.
from totp import totp
secret = b"12345678901234567890"
now = 1_700_000_000
for site in ["binance.com", "binаnce.com", "totally-not-a-phishing-site.xyz"]:
print(f"code your app shows for {site:32s}: {totp(secret, now)}")
You should see: one code, offered to all three:
code your app shows for binance.com : 921300
code your app shows for binаnce.com : 921300
code your app shows for totally-not-a-phishing-site.xyz : 921300
Now look at the generator in step 1 again: the site is not one of its inputs. A six-digit code cannot tell the real exchange from a lookalike, because it never knew which site was asking. This is why 2FA codes do not stop a convincing phishing page — you read the code out, the attacker types it into the real site within your thirty seconds, and both of you are logged in.
If not: if the three codes differ, the now variable is being
replaced inside the loop — it must stay fixed. There is deliberately no way to make
this function depend on the site; that absence is the finding.
Go: same folder. This is what a hardware security key or a passkey does, reduced to its essential move.
Do: save this as securitykey.py and run it. You will play
the victim on a lookalike domain while the attacker relays your proof to the real exchange.
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
from cryptography.exceptions import InvalidSignature
key = Ed25519PrivateKey.from_private_bytes(bytes(range(32))) # a fixed practice key
pub = key.public_key()
def security_key_signs(challenge, site_you_are_really_on):
"""A hardware key signs the challenge TOGETHER WITH the site's name."""
return key.sign(challenge + b"|" + site_you_are_really_on.encode())
challenge = b"login-attempt-8842"
proof = security_key_signs(challenge, "xn--binnce-5nf.com")
try:
pub.verify(proof, challenge + b"|" + "binance.com".encode())
print("binance.com ACCEPTED the relayed proof")
except InvalidSignature:
print("binance.com REJECTED the relayed proof -- wrong site baked in")
You should see: the relay failing:
binance.com REJECTED the relayed proof -- wrong site baked in
The whole difference is the site’s name inside the signature. Your browser supplies it — the phishing page cannot lie about which domain it is, because the browser, not the page, decides what gets signed. The proof is therefore useless anywhere except the site it was made for. On an exchange holding real money, a hardware key or a passkey is not a marginal upgrade over a code app; it removes the attack that actually happens.
If not: if it prints ACCEPTED, the two site strings were made identical
— the signing call must use the impostor domain and the verify call the real one.
ModuleNotFoundError: No module named 'cryptography' means the library is
missing; run pip install cryptography.
Without scrolling up: you type your password and 2FA code into a page that looks exactly like your exchange. The page says “verification failed, try again”, you enter a second code, and it works. What just happened, and what should you do in the next ten minutes? Answer: the first code was relayed to the real exchange to log the attacker in, and the “failure” existed only to harvest a second code — usually the one that authorises a withdrawal or a new device. In the next ten minutes: log in to the real exchange from a bookmark you typed yourself, change the password, revoke every active session and API key, and check the withdrawal whitelist for an address you did not add. Then replace the code app with a hardware key or passkey, because the same attack works again tomorrow.
Now do it without the page: change security_key_signs so the
signature covers the challenge, the site and the amount being withdrawn, then show
that a proof made for “withdraw 0.1” fails verification against
“withdraw 10”. You have just reinvented transaction signing — the reason a
hardware wallet asks you to confirm the amount on its own screen.
Summary
- Use a strong, unique password stored in a password manager
- Enable TOTP or hardware key 2FA — never rely on SMS alone
- Set up anti-phishing codes and withdrawal address whitelisting
- Secure your email account with the same level of protection
- Review sessions, devices, and API keys regularly
- Act immediately if you suspect any unauthorized access
Remember: the safest approach is to keep only what you need for active trading on the exchange and withdraw the rest to your own wallet.