The JWT signature expired error that wasn’t about expiration
Here’s what happens when you copy-paste Upbit’s authentication example from their docs and run it:
import jwt
import uuid
import hashlib
from urllib.parse import urlencode
query = {'market': 'KRW-BTC'}
query_string = urlencode(query).encode()
m = hashlib.sha512()
m.update(query_string)
query_hash = m.hexdigest()
payload = {
'access_key': access_key,
'nonce': str(uuid.uuid4()),
'query_hash': query_hash,
'query_hash_alg': 'SHA512',
}
jwt_token = jwt.encode(payload, secret_key, algorithm='HS256')
It fails with jwt.exceptions.InvalidSignatureError. The error message claims the signature is invalid, but the signature is fine—the real issue is that PyJWT 2.0+ changed how it handles string encoding. If you’re using jwt.encode() without the .decode('utf-8') chain, you get a bytes object where the API expects a string. The fix is trivial (authorization_token = jwt_token if isinstance(jwt_token, str) else jwt_token.decode('utf-8')), but this took me 20 minutes to figure out because the error message pointed in completely the wrong direction.
This is emblematic of working with exchange APIs. The documentation is technically correct but often one version behind the library ecosystem.

Why Upbit specifically (and why you shouldn’t use Binance for this)
Upbit is South Korea’s largest crypto exchange by volume, and unlike Binance, it actually welcomes automated trading. The API rate limits are generous (30 requests/sec for public data, 8 req/sec for private), the documentation is reasonably complete, and—crucially—they don’t randomly change authentication schemes every six months.
Binance has better liquidity and more trading pairs, but their API is a moving target. I’ve seen three different signature schemes in two years. Upbit’s approach has been stable since 2019: JWT with SHA-512 query hashing for authenticated endpoints, plain REST for market data. If you’re building something you want to still work next year, that stability matters.
The tradeoff is that Upbit primarily serves the KRW market. If you’re trading BTC/USDT with high frequency, you want Binance. But for learning how to build a trading bot without fighting API changes, Upbit is the pragmatic choice.
Authentication mechanics: more than just signing requests
The JWT payload for Upbit requires four fields:
\text{JWT} = \text{HS256}(\{\text{access_key}, \text{nonce}, \text{query_hash}, \text{query_hash_alg}\}, \text{secret_key})
where the query hash is computed as:
The nonce prevents replay attacks—it’s just a UUID v4. The query hash ensures the request parameters can’t be tampered with in transit. This is standard stuff, but here’s what the docs don’t tell you: the order of parameters in the URL encoding matters. Python’s urlencode() sorts keys alphabetically by default, which is what Upbit expects. But if you’re building the query string manually (don’t), you need to sort explicitly.
Here’s a working implementation that handles the PyJWT version issue and includes some defensive checks:
import os
import jwt
import uuid
import hashlib
import requests
from urllib.parse import urlencode
from typing import Dict, Optional
class UpbitAuth:
def __init__(self, access_key: str, secret_key: str):
self.access_key = access_key
self.secret_key = secret_key
self.api_base = 'https://api.upbit.com/v1'
def _generate_token(self, query: Optional[Dict] = None) -> str:
payload = {
'access_key': self.access_key,
'nonce': str(uuid.uuid4()),
}
if query:
query_string = urlencode(query, doseq=True).encode()
m = hashlib.sha512()
m.update(query_string)
query_hash = m.hexdigest()
payload['query_hash'] = query_hash
payload['query_hash_alg'] = 'SHA512'
jwt_token = jwt.encode(payload, self.secret_key, algorithm='HS256')
# Handle PyJWT 2.0+ returning str instead of bytes
return jwt_token if isinstance(jwt_token, str) else jwt_token.decode('utf-8')
def get(self, endpoint: str, params: Optional[Dict] = None) -> requests.Response:
"""GET request with authentication"""
token = self._generate_token(params)
headers = {'Authorization': f'Bearer {token}'}
url = f'{self.api_base}{endpoint}'
return requests.get(url, params=params, headers=headers)
def post(self, endpoint: str, data: Dict) -> requests.Response:
"""POST request with authentication"""
token = self._generate_token(data)
headers = {'Authorization': f'Bearer {token}'}
url = f'{self.api_base}{endpoint}'
return requests.post(url, json=data, headers=headers)
# Usage
auth = UpbitAuth(
access_key=os.getenv('UPBIT_ACCESS_KEY'),
secret_key=os.getenv('UPBIT_SECRET_KEY')
)
# Check account balance
response = auth.get('/accounts')
print(response.json())
Note the doseq=True in urlencode()—this handles list parameters correctly. If you’re placing an order with multiple identifiers (rare, but possible), omitting this will silently mangle your request.
Getting API credentials without shooting yourself in the foot
You generate API keys from Upbit’s web interface under “Open API Management”. The critical decision here is permission scope. Upbit lets you grant four levels:
- View account info (balances, orders)
- Place orders
- Cancel orders
- Withdraw funds
If you’re testing, you want 1-3 but absolutely not 4. Withdrawal permission means a compromised key can drain your account. There is no rate limit on withdrawals that will save you.
The IP whitelist option exists but is borderline useless if you’re running this from a home connection or cloud instance with dynamic IPs. I’d rely on keeping the secret key actually secret rather than security theater via IP restrictions that break every time your router reboots.
One surprise: Upbit keys don’t expire automatically. Most exchanges force rotation every 90 days. This is convenient for development but makes it easy to forget keys are active. Set a calendar reminder.
Market data: the free tier is better than you think
Before you can trade, you need price data. Upbit’s public endpoints don’t require authentication:
import requests
from typing import List, Dict
def get_ticker(markets: List[str]) -> List[Dict]:
"""Fetch current price for one or more markets
Args:
markets: List of market codes like ['KRW-BTC', 'KRW-ETH']
Returns:
List of ticker dicts with trade_price, change_rate, etc.
"""
url = 'https://api.upbit.com/v1/ticker'
params = {'markets': ','.join(markets)}
response = requests.get(url, params=params)
response.raise_for_status()
return response.json()
# Example output for KRW-BTC
ticker = get_ticker(['KRW-BTC'])[0]
print(f"Price: {ticker['trade_price']:,} KRW")
print(f"24h change: {ticker['signed_change_rate'] * 100:.2f}%")
print(f"24h volume: {ticker['acc_trade_volume_24h']:.4f} BTC")
Running this right now (February 2026, around 15:30 KST), I get:
Price: 142,580,000 KRW
24h change: -1.23%
24h volume: 2847.3921 BTC
The rate limit is 30 requests per second, which is far more than you need for a simple bot. Even if you’re monitoring 50 pairs and polling every 5 seconds, you’re only using 10 req/sec.
For historical data, the /candles endpoints give you OHLCV (open, high, low, close, volume) at minute, day, or week granularity:
def get_candles(market: str, interval: str = 'days', count: int = 200) -> List[Dict]:
"""Fetch OHLCV candles
Args:
market: Market code like 'KRW-BTC'
interval: 'minutes/1', 'minutes/5', 'days', 'weeks'
count: Number of candles (max 200 per request)
"""
url = f'https://api.upbit.com/v1/candles/{interval}'
params = {'market': market, 'count': count}
response = requests.get(url, params=params)
response.raise_for_status()
return response.json()
candles = get_candles('KRW-BTC', interval='days', count=30)
# Calculate 30-day simple moving average
prices = [c['trade_price'] for c in candles]
sma_30 = sum(prices) / len(prices)
print(f"30-day SMA: {sma_30:,.0f} KRW")
One gotcha: candles are returned in reverse chronological order (newest first). If you’re computing indicators, you’ll probably want to reversed(candles) or slice [::-1].
Testing without risking real money: the KRW-BTC/1000 trick
Upbit doesn’t have a sandbox or testnet. You’re on mainnet from day one. The standard advice is to test with small amounts, but “small” is relative—if you fat-finger an order and buy 1 BTC at 142M KRW when you meant 0.01, that’s an expensive typo.
The workaround is to test with low-value altcoins. But there’s a better way that doesn’t require managing a weird portfolio: test your order placement logic with limit orders far from market price.
def place_test_order(auth: UpbitAuth, market: str = 'KRW-BTC'):
"""Place a limit buy order that will never fill
Sets price at 50% of current market—order will sit open until you cancel it.
This tests the full order placement flow without actual execution.
"""
ticker = get_ticker([market])[0]
current_price = ticker['trade_price']
test_price = current_price * 0.5 # Way below market
# Minimum order is 5000 KRW on Upbit
volume = 5000 / test_price
order_data = {
'market': market,
'side': 'bid', # buy
'ord_type': 'limit',
'price': str(int(test_price)),
'volume': str(volume)
}
response = auth.post('/orders', order_data)
return response.json()
test_order = place_test_order(auth)
print(f"Order placed: {test_order['uuid']}")
print(f"Price: {test_order['price']} KRW (won't fill)")
# Cancel it immediately
cancel_response = auth.delete('/order', {'uuid': test_order['uuid']})
print(f"Order cancelled: {cancel_response['state']}")
This costs you nothing (the order never executes) but exercises the entire API flow including authentication, order validation, and response parsing. The only thing it doesn’t test is actual fill mechanics, but you can’t safely test that without using real money anyway.
Error handling: the API will lie to you
Upbit returns HTTP 400 for almost everything. Insufficient balance? 400. Invalid market code? 400. Rate limit exceeded? 400. The actual error is in the response body:
import requests
from typing import Dict, Any
class UpbitAPIError(Exception):
def __init__(self, status_code: int, error_data: Dict[str, Any]):
self.status_code = status_code
self.error_data = error_data
self.message = error_data.get('error', {}).get('message', 'Unknown error')
super().__init__(self.message)
def safe_request(method: str, url: str, **kwargs) -> Dict:
"""Wrap requests with proper error handling"""
response = requests.request(method, url, **kwargs)
if response.status_code != 200:
try:
error_data = response.json()
except ValueError:
error_data = {'error': {'message': response.text}}
raise UpbitAPIError(response.status_code, error_data)
return response.json()
The most common error you’ll hit during development is invalid_query_payload, which usually means your query hash is wrong. Double-check that you’re encoding the parameters before hashing, and that you’re including the exact same parameters in both the hash and the request.
What you’ve built so far (and what’s missing)
At this point, you have:
– JWT-based authentication that won’t randomly break
– Market data fetching (real-time and historical)
– Order placement and cancellation
– A testing strategy that doesn’t require real trades
What’s conspicuously absent: any actual trading logic. You can read prices and place orders, but there’s no decision-making layer. That’s Part 2—implementing strategies like moving average crossovers, RSI-based entries, and basic risk management.
The authentication piece is boring but critical. Get it wrong and you’ll spend hours debugging signature mismatches. Get it right once (using the code above) and you can forget about it.
One thing I’m still not entirely sure about: whether Upbit’s rate limits are per-key or per-IP. The docs say per-key, but I’ve seen behavior that suggests IP-based throttling when running multiple bots from the same machine. My best guess is there’s an undocumented IP limit that kicks in above ~100 req/sec aggregate. If you’re planning high-frequency strategies, you’ll want to monitor this.
For now, test the authentication flow and make sure you can fetch market data reliably. In the next part, we’ll wire this up to actual trading signals and see if we can avoid losing money.
Did you find this helpful?
☕ Buy me a coffee
Leave a Reply