How I get N26 API and automate a transfer

Fabrizio Waldner


Nowadays, the computer science is very advanced; and I expect to simplify my life automating and integrating several components.

For example, have you ever think about keep track of the payments of your tenants and send an email to who forgot to pay the rent?

I found N26 ( very flexible in these terms. Exploiting the API you can create your own logic to handle your account.

You can find some unofficial API documentation on internet, but what I didn’t find was how to do a bank transfer. Actually I found some specification about transfer, but they are not up to date, so not working.

So I started to do reverse engineering to reveal the API, in my job as system administrator, I use to do reverse engineering when there is lack of documentation.


Before publish this article I asked to the N26 security office. They gave me the permission to publish it even it is not mandatory. It was for my politeness, because I know that the security is a delicate matter. I want thank them because they give their point of view and I was able to understand better why some mechanism are implemented.

I put in quotes their comments/explanations.

Update on 5th October 2019

Since mid of September 2019, N26 has enforced the authentication method utilizing the Multi Factor Authentication. In this moment, the script that I provided at the end of this story is not working anymore.

The path to the secret

Starting point

I started googling for N26 API. One of the first results is the “N26 Bug Bounty Program” ( In this page N26 give some advises to find bug or security problem on its infrastructure. It mentions the endpoints and the existence of some unofficial documentation of the API.

The entrypoint is

I pointed my browser to (where N26 said that the API are used), this host redirected me on
Unfortunately, using the dev console of Firefox, I can not see any query towards the api Mmmh, probably the information on the bug bounty program is not up to date.

Security office promised me to update this information on their bug bounty program page.

I started to look what other people already found. For example in Github project of PierrickP ( or the project of Zilverline ( where there is an example to retrieve the access token.

So I started to use some call to interact with API:

curl -k -H "Authorization:Basic bXktdHJ1c3RlZC13ZHBDbGllbnQ6c2VjcmV0" -H "Content-Type: application/x-www-form-urlencoded" -d "grant_type=password&username=myemail&password=mypassword" -X POST

I played with some calls that I found in that repository and, finally, I tried to do a transfer with this call:

curl -H "Authorization:Bearer mytoken" -d '{"pin": "myPIN", "transaction": { "partnerIban": "myotherIBAN", "partnerBic": "BICofBank", "partnerAmount": "100", "partnerName": "myName", "referenceText": "my test transfer", "type": "DT" }}'  -H "Content-Type: application/json" -X POST
Error: Update your App

Mmmh, “update your App”? This statement says me 2 things:
- the founded API are old
- I can search for other information in the App

MITM and smartphone

So I have to sniff the communications between the N26 App and the server time for a Man In The Middle (

I started to create my own CA and then a sign a certificate that has the

# create  the CA key
openssl genrsa -des3 -out myCA.key 2048
# Create the CA certificate
openssl req -x509 -new -nodes -key myCA.key -sha256 -days 1825 -out myCA.pem
# Create the key for the certificate
openssl genrsa -out 2048
# Create certificate request
openssl req -new -config -key -out
# Use the CA to sign the request
openssl x509 -req -in -CA myCA.pem -CAkey myCA.key -CAcreateserial -out -days 1825 -sha256 -extensions x509_ext -extfile

And setup a simple Nginx server as reverse proxy to see the traffic:

log_format postdata '[$time_local] "$request" $status '  
'$body_bytes_sent "$http_referer" '
'"$http_user_agent" [$request_body]';
server {
listen 443 ssl;
ssl_certificate certs/;
ssl_certificate_key certs/;
ssl_session_cache shared:SSL:1m;
ssl_session_timeout 5m;
error_log stderr debug;ssl_ciphers HIGH:!aNULL:!MD5;
ssl_prefer_server_ciphers on;
location / {
proxy_pass_header Server;
proxy_set_header Host $host;
access_log /dev/stdout postdata;
proxy_redirect off;

I changed the resolution IP for on my router, I installed my own CA on my smartphone and here we are.

The first attempt failed, it didn’t recognize the CA, I could see that on a Wireshark capture. The problem was that I used an Android 8 smartphone, because using an Android 5 smartphone, even the custom CAs are trusted.
Now, at level of TLS session we are OK, but the App closed the connection just finished the TLS handshake. I thought there are some restriction on certification.

App deobfuscating and tampering

I started on documenting about security of N26, and I found this very interesting research of Dominik Maier ( I read it all and it speaks about “certificate pinning”, it was missing and now they inserted it in.

Certificate pinning consists in insert a reference about some certificates that the App have to trust. To circumvent this security I have to touch the App.

There are a lot of tool to decompile and recompile an Android App.
I used 2:

From the deobfuscated code I found that N26 uses OkHttp library and this library implements certificate pinning ( So the research continued, I found an article of Rick Ramgattie ( about bypass certificate pinning.

# decompile the APK
apktool -r d extracted/base.apk -o base_to_patch
# find where fingerprints are used
grep sha256 -R base_to_patch/
./smali/com/n26/base/e/c/p.smali: const-string v3, "sha256/UFZ3yMGKM7egmNZTeK1gc5Sz/n1K/3GfWtK1RsIHDdY="
./smali/com/n26/base/e/c/p.smali: const-string v3, "sha256/uDmpTbrFp0OubCwvUNAjlvK4nCLkFZWzCa5xxNpmC3c="
./smali/com/n26/base/e/c/p.smali: const-string v3, "sha256/UFZ3yMGKM7egmNZTeK1gc5Sz/n1K/3GfWtK1RsIHDdY="
./smali/com/n26/base/e/c/p.smali: const-string v3, "sha256/WijnnlKgNnTQfDDI3TGzo9Vy6ERX/yP02FyL5iBM4Bc="
./smali/com/n26/base/e/c/p.smali: const-string v3, "sha256/CMpp+jeqJre03CLCWQTRvC6nsB6eSYpz7xCJzRRlm44="
./smali/com/n26/base/e/c/p.smali: const-string v3, "sha256/pk4REvQs+gL1agHkgWfcAEWpe6BGwJZLj50NjQ8C65Y="
# Compute the fingerprint of my own certificate
openssl x509 -in ../ -pubkey -noout | openssl rsa -pubin -outform der | openssl dgst -sha256 -binary | openssl enc -base64
# change an hash inserting my hash
vi ./smali/com/n26/base/e/c/p.smali
# recompile
apktool b base_to_patch/ -o base_patched.apk
# sign the apk
jarsigner -verbose -sigalg SHA1withRSA -digestalg SHA1 -keystore my-release-key.keystore base_patched.apk alias_name
# install on smartphone
adb install base_patched.apk

And here we are, these the log of my Nginx:

INFO  ==> ** Starting NGINX **
[06/Jun/2019:10:20:51 +0000] "GET /api/version/mobile?os=android HTTP/1.1" 200 499 "-" "n26-android_9.99.9" [-]
[06/Jun/2019:10:20:56 +0000] "POST /oauth/token HTTP/1.1" 200 186 "-" "n26-android_9.99.9" [username=myemail&password=mypassword&grant_type=password]
[06/Jun/2019:10:20:56 +0000] "GET /api/smrt/categories HTTP/1.1" 200 89477 "-" "n26-android_9.99.9" [-]
[06/Jun/2019:10:20:58 +0000] "PUT /api/notificator/devices HTTP/1.1" 200 0 "-" "n26-android_9.99.9" [{\x22platform\x22:\x22ANDROID\x22,\x22publicKey\x22:\x22*****\x22,\x22token\x22:\x22*******-\x22}]
[06/Jun/2019:10:20:58 +0000] "GET /api/products HTTP/1.1" 200 2021 "-" "n26-android_9.99.9" [-]
[06/Jun/2019:10:20:59 +0000] "GET /api/me?full=true HTTP/1.1" 200 2291 "-" "n26-android_9.99.9" [-]
[06/Jun/2019:10:21:00 +0000] "GET /api/v2/cards HTTP/1.1" 200 394 "-" "n26-android_9.99.9" [-]
[06/Jun/2019:10:21:01 +0000] "GET /api/v2/translations/android+strings,credit,Savings,Overdraft,Signup+Mobile,Mobile,InsuranceWallet,KYC,Spaces,Google+Pay,Salesforce+Chat,Certification,Transactions,Feed/it HTTP/1.1" 200 86049 "-" "n26-android_9.99.9" [-]

Spying the communications

Ok, so with my reverse proxy Nginx I can see the posted data, but I can not see the headers.
Then, I turned to use mitmdump (, a tool specifically designed to do MITM sniffing.

The syntax for doing a reverse proxy tracing the headers and body is:

sudo mitmdump -vvv  --mode reverse: -p443 --ssl-insecure --setheader --certs certs/ -w /tmp/dump.proxy  >> /tmp/log.proxy

The beautiful aspect of mitmdump is that you can save on a file the conversation between client and server (dump.proxy). This file can then be used to simulate a connection towards to the real server. Moreover, mitmdump allows to substitute headers or body with a regex.

I collected some calls, but specifically I collected the dialogue for a transfer: POST
routing: dee1fd7fabcdefabce4799a7b76abdac8a7a54ab3b661142e
User-Agent: n26-android_9.99.9
Authorization: Basic bmF0aXZlYW5kcm9pZDo=
Content-Type: application/x-www-form-urlencoded
Content-Length: 74
Connection: Keep-Alive
Accept-Encoding: gzip
username: *******
password: ********
grant_type: password
<< 200 OK 177b
Date: Mon, 03 Jun 2019 13:14:12 GMT
Content-Type: application/json;charset=UTF-8
Transfer-Encoding: chunked
Connection: keep-alive
Server: nginx
Vary: Accept-Encoding
cache-control: no-store
x-xss-protection: 1; mode=block
pragma: no-cache
x-frame-options: DENY
server-timing: intid;desc=e897e1947eee6215
x-content-type-options: nosniff
x-envoy-upstream-service-time: 96
X-Frame-Options: SAMEORIGIN
X-Content-Type-Options: nosniff
X-XSS-Protection: 1; mode=block
Content-Encoding: gzip
"access_token": "*****************",
"expires_in": 1799,
"host_url": "",
"refresh_token": "***********",
"scope": "trust",
"token_type": "bearer"
User-Agent: n26-android_9.99.9
Authorization: Bearer **************
Connection: Keep-Alive
Accept-Encoding: gzip
If-Modified-Since: Sun, 02 Jun 2019 19:10:28 -0000
<< 200 OK 367b
Date: Mon, 03 Jun 2019 13:14:42 GMT
Content-Type: application/json;charset=UTF-8
Transfer-Encoding: chunked
Connection: keep-alive
Server: nginx
Vary: Accept-Encoding
x-envoy-upstream-service-time: 10
x-content-type-options: nosniff
x-frame-options: DENY
x-xss-protection: 1; mode=block
X-Frame-Options: SAMEORIGIN
X-Content-Type-Options: nosniff
X-XSS-Protection: 1; mode=block
Content-Encoding: gzip
"publicKey": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEApos8rWCF1nE88M2QdxeZuGdSke+9vXPZw0Qo1iQ+X78oRBwwOa5ILrhpoG2DBwsR+aYVIFb2KHelvIvuL+UOHSaY53al2UM3cONOx7IE
Encrypted-Pin: ***base64_of_16_bytes***
Encrypted-Secret: ****base64_of_141_bytes*****
User-Agent: n26-android_9.99.9
Authorization: Bearer ********************************
Content-Type: application/json; charset=UTF-8
Content-Length: 185
Connection: Keep-Alive
Accept-Encoding: gzip
"pin": "****",
"transaction": {
"amount": "25.0",
"partnerBic": "*****BIC***",
"partnerIban": "****IBAN*****",
"partnerName": "*****NAME****",
"referenceText": "*******",
"type": "DT"

The dialogue can be summarized in:

  • Get the bearer token: POST ouath/token
  • Get the public key to encrypt some data: GET api/encryption/key
  • Put the transfer data and some encrypted data to validate the transfer: POST api/transactions

Decrypting the mechanism

Now, my attention is focused on how I can validate my transfer, and Encrypted-Secret and Encrypted-Pin headers are the key.

I analyzed the public key passed with GET api/encryption/key step and I was able to see that it is a public RSA of 141 bytes extracted from a 2048 bytes private key.

There is a rule of thumb in cryptography, the encrypted data are multiple of the length of the key used to crypt. So, probably the Encrypted-Secret header is encrypted with the public key passed.

It is almost impossible to decrypt without the private key, but I can use my own key pair. In this case I used mitmdump to substitute the public key with a mine one.

# Generate private key
openssl genrsa -des3 -out myprivate.key 2048
# Export public key
openssl rsa -in myprivate.key -outform PEM -pubout -out public.pem
# Start reverse proxy with substitution
mitmdump -vvv --mode reverse: \
-p443 --ssl-insecure --setheader \
--certs certs/ \
-S /tmp/dump.proxy \
--set flow_detail=3 \
--server-replay-kill-extra \
--replacements :~s:MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEApos8rWCF1nE88M2QdxeZuGdSke\\+9vXPZw0Qo1iQ\\+X78oRBwwOa5ILrhpoG2DBwsR\\+aYVIFb2KHelvIvuL\\+UOHSaY53al2UM3cONOx7IEohCrBcsWpIkVdKTe29AV50L2fV391EPR0R3wHXVXf9qQR9hGZsAqZ65SWn/bTChvHcL5QoQBoU/jUdkJIxMb3ktRMfCmv\\+oE1oKIS/cIPGvlEw\\+qhfkbh\\+On177thrgoe2DeyxkyvU7d1j7yBBYyxJItVU88TRdmyFKXtL3pp5DuUri0oL7W2uOBqCxhSoSLizKJ4ovERf1YerCMU8ZI5fwqPCjXK5TIMYCSXoEP7WEQywIDAQAB:MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA0ttFhH4WfXGCvI41enBUeOOpwGhxCkzhibWkVgnkubZcgQsl/SeJdnLNmVZEuuQoB0cYr63FbMI7whvGoaGCryClaY2zaPlsmnaRwvzhc5dg12J7x9O9I95Vg3UdfcSfWbicUCPHneM+FEoJs6rdW98GmitWdSMdVH1IQoNtCbD2Q4v+ShxU8xGiad2uTCh7xtfhxi0H7p0O1gRd3KeeuLRJ0g8Np+3mSoqgdYRohpXq0iKGoc9eRn1mEZL49eB2oQ3RMEC6E0nQv6R2xNmvym0PEfwXb3lylu2K7RwbVGExGkqkLkmO7Qh9hk9jDAq42YoBhya7a9dQ/nU7AXsObQIDAQAB >> /tmp/log.proxy

With this command it reads the dump /tmp/dump.proxy to simulate the dialogue; it avoids to ask to the server with --server-replay-kill-extra and replaces the answer from the server with --replacements :~s:escaped_N26_public_key:my_public_key .

Ok, so I collected some Encrypted-Secret and Encrypted-Pin encrypted with my own key.

As said before, the public key and the Encrypted-Secret have the same length, so I tried to decrypt with RSA algorithm.

# decode from base64
base64 -d Encrypted-Secret.orig > Encrypted-Secret.nobase
# Decrypt with my own private key
openssl rsautl -decrypt -in Encrypted-Secret.nobase -inkey ./myprivate.key

Wow! I obtained two arrays: iv and secret. Pay attention, they are 16 bytes length, the value of single integer is between -128 and 127, so they are signed bytes. I am not a security researcher, but my experience said me that iv stand for initialization vector: it is normally used in CBC encryption.
At this moment, I was not sure how use this arrays, so I started to dig inside the deobfuscated sources. In the code there are a lot of code about encryption because they come from the library Bouncycastle, I focused my attention on the code of developers, in particular I found this class:

import javax.crypto.Cipher;
import javax.crypto.spec.IvParameterSpec;
import kotlin.p820e.p821b.C14870k;
/* compiled from: AesEncrypter.kt */
/* renamed from: */
public final class C1635c {
/* renamed from: a */
public final byte[] m4919a(byte[] bArr, Key key, byte[] bArr2) {
C14870k.b(bArr, "bytesToEncrypt");
C14870k.b(key, "secretKey");
C14870k.b(bArr2, "initializationVector");
try {
Cipher instance = Cipher.getInstance("AES/CBC/PKCS5Padding");
instance.init(1, key, new IvParameterSpec(bArr2));
bArr = instance.doFinal(bArr);
C14870k.a(bArr, "cipher.doFinal(bytesToEncrypt)");
return bArr;
} catch (byte[] bArr3) {
throw ((Throwable) new IllegalArgumentException("Exception raised during AES encryption", (Throwable) bArr3));

I found a useful site where I can decrypt AES-128 (=16*8bytes):

To fill the forms of the site page, I have to convert to hex, so I scripted:

base64 -d Encrypted-Pin.orig | hexdump -C
echo IV=
arr="$( echo -77,12,4,-123,76,-39,-45,90,66,-60,-77,-121,98,-123,103,-10 | tr ',' '\n')"
for num in "${arr[@]}" ; do printf "%02x\n" $num | rev | cut -c 1-2 | rev; done
echo SECRET=
arr="$( echo -3,56,-24,124,68,-41,-79,32,61,-106,83,-33,120,55,-5,109 | tr ',' '\n')"
for num in "${arr[@]}" ; do printf "%02x\n" $num | rev | cut -c 1-2 | rev; done
======================== Cleaned Output =======================
ENCODED= ** ** ** ** ** ** ** ** ** ** ** ** ** ** ** **
IV= b3 0c 04 85 4c d9 d3 5a 42 c4 b3 87 62 85 67 f6
SECRET= fd 38 e8 7c 44 d7 b1 20 3d 96 53 df 78 37 fb 6d

And I inserted in the online decrypter:

Voilà, the decrypted content is my PIN, the PIN that I have to use to confirm the transaction. The same PIN is passed in plain text in the body, but without the Encrypted-Pin header, the transfer is not accepted.

Security team said me that the plain PIN will disappear in the next versions of the API, now they are experiencing the transient to be retro-compatible.

Now, I probably can do a transfer. To do it, I generated an arbitrary and simple encryption of my PIN:

And I tried with some curl, it works, the iv and secret are arbitrary.

Scripted transfer

Schema of the encryption

Finally, I can translate all together in a script:

motivation="Scripted transfer"
recipient="Fabrizio Waldner"
respToken=$(curl -k -H "Authorization:Basic bmF0aXZlYW5kcm9pZDo=" -H "Content-Type: application/x-www-form-urlencoded" -d "grant_type=password&username=$email&password=$password" -X POST
token=$(echo $respToken | jq -r '.access_token')
respKey=$(curl -k -H "Authorization:Bearer $token"
publicKey=$(echo $respKey | jq -r '.publicKey')echo "-----BEGIN PUBLIC KEY-----" > /tmp/pubkey
echo "$publicKey" >> /tmp/pubkey
echo "-----END PUBLIC KEY-----" >> /tmp/pubkey
encryptedSecret=$(echo '{"iv":[0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15],"secretKey":[0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15]}' | openssl rsautl -encrypt -inkey /tmp/pubkey -pubin | base64 -w0)
encryptedPin=$(echo -n $pin | openssl enc -aes-128-cbc -K 000102030405060708090a0b0c0d0e0f -iv 000102030405060708090a0b0c0d0e0f | base64 -w0)
curl -k -H "Authorization:Bearer $token" \
-d "{\"pin\": \"$pin\", \"transaction\": { \"partnerIban\": \"$IBAN\", \"partnerBic\": \"$BIC\", \"amount\": \"$amount\", \"partnerName\": \"$recipient\", \"referenceText\": \"$motivation\", \"type\": \"DT\" }}" \
-H "Content-Type: application/json; charset=UTF-8" \
-H "Encrypted-Pin: $encryptedPin" \
-H "Encrypted-Secret: $encryptedSecret" \


I found very strange that the process crypts the PIN with a secret (encrypted) that is given together. I think this doesn’t add security, the security is provided by the asymmetrical encryption with the public key. It seems “security through obscurity” paradigm, paradigm that normally one should avoid.

Security team explained me that in reality this mechanism adds more security. Actually, crypt a small amount of data (4 digits of the PIN) with the public key can lead to a cryptanalysis attack. To add entropy they introduced secret with iv. From this point of view I can agree with them.

This article doesn’t show security problems, but it want be an help who wants to start the bug bounty program. I think security is sharing of the knowledge and open sourcing the mechanism.

But most of all, now I can do a transfer with in automated way with the above script.

Fabrizio Waldner

Written by

Linux SysAdmin

Welcome to a place where words matter. On Medium, smart voices and original ideas take center stage - with no ads in sight. Watch
Follow all the topics you care about, and we’ll deliver the best stories for you to your homepage and inbox. Explore
Get unlimited access to the best stories on Medium — and support writers while you’re at it. Just $5/month. Upgrade