NordVPN over WireGuard: on GL.iNet the token is enough
On a GL.iNet the token is enough and the router does the rest. Everywhere else the private key must be pulled from the account API: how, and from which machine.
NordVPN does not hand out ready-made .conf files. NordLynx is their WireGuard implementation, but the private key appears nowhere in the console: it has to be pulled from the account API.
With one exception, which is worth getting out of the way first.
On a GL.iNet, the token is enough
GL.iNet’s firmware 4.x has a built-in NordVPN client, and it does on its own everything the rest of this guide does by hand.
- VPN → WireGuard Client, then NordVPN → Edit account information
- Paste the access token — how to get one is the first step of the long procedure below — and press Next
- The router downloads the server list, over two thousand of them, and presents it by country and city
- Pick one and press Apply
No key to extract, no file to compose. Under the hood the router calls the very same APIs described below: it takes the account’s private key and the servers’ public keys, and keeps them without ever showing them. The list you see in the selection window is the response from recommendations.
Verified on firmware 4.8.1.
When you do need the long way
Everything below is for devices that have no built-in NordVPN client, where the keys are yours to obtain:
- pfSense and OPNsense
- Stock OpenWrt, without GL.iNet’s firmware
- MikroTik on RouterOS 7
- The WireGuard clients on Windows, Linux, macOS, Android and iOS
- Other brands of router with generic WireGuard
There, nobody calls the API on your behalf: you need the account’s private key, the chosen server’s public key, and a hand-written .conf.
What you need
- An active NordVPN subscription and access to
my.nordaccount.com - A machine with PowerShell, or
curlon Linux, macOS or WSL
1. Generate the access token
- Sign in to
my.nordaccount.com - In the sidebar, NordVPN
- Scroll to Advanced settings → Set up NordVPN manually
- Nord asks you to verify your email: enter the six-digit code
- The manual setup page has two distinct boxes: Service credentials, which is the username and password for OpenVPN and is not what you want here, and Access token, which is
- Press Generate new token and, if asked for a lifetime, pick no expiry
2. Extract the private key
On Windows, in PowerShell:
# PowerShell 5.1 only: force TLS 1.2 for the current session
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
$token = "PASTE_YOUR_TOKEN_HERE"
# Sanity check: must print 64 and True
$token.Length
$token -match '^[0-9a-f]{64}$'
$b64 = [Convert]::ToBase64String([Text.Encoding]::ASCII.GetBytes("token:$token"))
$cred = Invoke-RestMethod -Uri "https://api.nordvpn.com/v1/users/services/credentials" `
-Headers @{ Authorization = "Basic $b64" }
$cred.nordlynx_private_key
On Linux, macOS or WSL:
curl -s -u token:PASTE_YOUR_TOKEN_HERE \
https://api.nordvpn.com/v1/users/services/credentials | jq -r .nordlynx_private_key
The result is a base64 string of 44 characters ending in =. Three things to know right away:
- It belongs to the account, not the server: the same key works with any Nord endpoint
- It does not expire on its own, but it is invalidated if you regenerate the service credentials
- It counts as a password: whoever holds it uses your subscription
Save it to a password manager before going any further.
3. Pick the country and the server
These calls require no authentication: the data is public, and you can run them from any machine.
$countries = Invoke-RestMethod "https://api.nordvpn.com/v1/servers/countries"
# full list, 149 countries
$countries | Sort-Object name | Select-Object id, name
# or look one up directly: names are in English
$countries | Where-Object { $_.name -eq 'Italy' } | Select-Object id, name
| Country | id |
|---|---|
| Belgium | 21 |
| Germany | 81 |
| Italy | 106 |
Those three are verified. For anything else, take the id from the live list: they follow no predictable order and can change.
Then the recommended servers, replacing 106 with the id you need:
$s = Invoke-RestMethod "https://api.nordvpn.com/v1/servers/recommendations?filters%5Bservers_technologies%5D%5Bidentifier%5D=wireguard_udp&filters%5Bcountry_id%5D=106&limit=5"
$s | Format-List name, hostname, station, load
hostname : it536.nordvpn.com
station : 187.13.214.133
load : 7
name : Italy #536
Pick the one with the lowest load, then take its public key:
($s[0].technologies | Where-Object identifier -eq 'wireguard_udp').metadata
For a server other than the first, use $s[1], $s[2] and so on: every server has its own key pair, so the public key must come from the right object.
station is the server’s public address. Use that in the endpoint rather than the hostname, so the router does not depend on DNS resolution at startup — which is precisely when the tunnel is not up yet.
4. Compose the .conf file
[Interface]
PrivateKey = YOUR_PRIVATE_KEY
Address = 10.5.0.2/32
DNS = 103.86.96.100, 103.86.99.100
MTU = 1420
[Peer]
PublicKey = SERVER_PUBLIC_KEY
AllowedIPs = 0.0.0.0/0
Endpoint = SERVER_IP:51820
PersistentKeepalive = 25
| Field | Value | Where it comes from |
|---|---|---|
PrivateKey | the account key | step 2 |
Address | 10.5.0.2/32 | fixed: NordLynx always assigns this |
DNS | the two Nord resolvers | fixed |
MTU | 1420 | NordLynx standard |
PublicKey | the server key | step 3 |
Endpoint | station plus :51820 | step 3 |
AllowedIPs | 0.0.0.0/0 | all traffic through the tunnel |
PersistentKeepalive | 25 | keeps the NAT mapping alive |
5. Import the file
On pfSense, OPNsense or a WireGuard client the .conf goes wherever that system expects it: the fields are the same, only the form changes.
On a GL.iNet, take this route only if you want a profile the built-in client will not give you — a specific server, or altered parameters:
- Open the router console, usually
http://192.168.8.1 - VPN → WireGuard Client → Add New Profile
- Choose Manual Config and paste the contents of the
.conf - Give the profile a recognisable name, for example
Nord-IT-536 - Save and press Connect
The VPN section on GL.iNet routers holds three switches that on a plain firewall you would have to build by hand:
- Kill Switch — blocks traffic if the tunnel drops, instead of letting it leak out over the WAN in the clear. Turn it on
- Block Non-VPN Traffic — the stricter variant of the same idea
- VPN Policy, called Proxy Mode in some versions — sends only selected devices through the tunnel, filtered by MAC address, or only certain domains
NAT is handled by the firmware: there is nothing to configure.
6. Verify it works
Within a few seconds of Connect the profile switches to connected and the traffic counters start moving. Then, from a device behind the router:
- Open
https://ipinfo.io/json: you should see the Nord server’s address and the country you chose - Check for DNS leaks on a service like
dnsleaktest.com: the resolvers reported must be Nord’s, not your ISP’s
If the tunnel connects but nothing loads, the first suspect is MTU: drop to 1400, then 1380.
7. More countries, more profiles
The router holds several profiles and lets you switch between them from the console. To add one:
- Re-run step 3 with the new country id
- Duplicate the
.conf, changing only two lines:EndpointandPublicKey - Import it as a new profile, under a different name
PrivateKey, Address, DNS and MTU stay identical across every profile, because the private key belongs to the account and not to the server.
8. Afterwards, and over time
Clear the shell history on the machine you used for the extraction: the token sits there in plain text.
Remove-Item "$env:APPDATA\Microsoft\Windows\PowerShell\PSReadline\ConsoleHost_history.txt"
On Linux and macOS, ~/.bash_history or your shell’s equivalent.
Revoke the token from the Nord Account: you no longer need it, and the private key keeps working without it.
When a server is decommissioned the tunnel stops working. Re-run step 3, take another server’s address and public key, update the two lines.
Every active WireGuard tunnel counts against the subscription’s simultaneous-connection limit, exactly as the official app would.
When something does not add up
When the certificate is not the one it should be
PowerShell 5.1 negotiates TLS 1.0 by default, and the resulting error talks about a “trust relationship” and nothing else. The first thing to try is forcing TLS 1.2, as in step 2.
If the error persists, the problem is the certificate chain. Look at who signs it:
$c = New-Object Net.Sockets.TcpClient("api.nordvpn.com",443)
$s = New-Object Net.Security.SslStream($c.GetStream(),$false,{$true})
$s.AuthenticateAsClient("api.nordvpn.com")
[Security.Cryptography.X509Certificates.X509Certificate2]$s.RemoteCertificate | Format-List Subject,Issuer
$s.Close(); $c.Close()
Expected: Subject: CN=*.nordvpn.com, with a public authority as issuer — Sectigo, Google Trust Services.
Error 401, in order of likelihood
Truncated token. $token.Length must return 64. If it returns less, you selected with the mouse instead of using the copy button.
Empty variable. Pasting the lines one at a time, it happens that $token is never set. And after every correction to the token, $b64 must be regenerated: it does not update by itself, so you keep sending the old value.
Wrong value. You copied the OpenVPN password from the Service credentials box instead of the access token. They are both hex strings, on the same page.
Revoked token. Changing the account password invalidates every token.
The check that separates the cases: if https://api.nordvpn.com/v1/users/current also returns 401, the token is invalid outright and this is not a permissions question.
“An empty pipe element is not allowed”
In PowerShell, foreach as a statement cannot be piped. Wrap it:
$(foreach ($id in 106, 81, 21) { ... }) | Format-Table -AutoSize
Or use ForEach-Object, remembering that inside the URL the variable becomes $_ instead of $id.
Empty columns, or values collapsed into braces
Invoke-RestMethod already returns a deserialised array. Feeding it to a ForEach-Object that builds a [pscustomobject] produces a single row with lists in braces. Use Select-Object on the array directly:
$s | Select-Object hostname, station, load
The addresses look like they belong to another country
Do not trust address blocks from memory: NordVPN uses hosting providers that reassign ranges across continents. Check with an independent source:
Invoke-RestMethod "https://ipinfo.io/187.13.214.133/json"
The name field in Nord’s own response — Italy #536, for instance — is the authoritative reference for the country.
The tunnel connects but nothing loads
MTU too high: try 1400, then 1380. Or the DNS servers are not configured: without Nord’s resolvers some setups resolve nothing at all. Also check that AllowedIPs is 0.0.0.0/0.
Handshake never completes
Check that the endpoint is reachable on UDP port 51820, and that you copied the right server’s public key. If the line sits behind CG-NAT or a restrictive firewall, the 25-second keepalive helps but is not always enough.
API quick reference
| Purpose | Path | Auth |
|---|---|---|
| Service credentials | /v1/users/services/credentials | token |
| Account data | /v1/users/current | token |
| Country list | /v1/servers/countries | no |
| Recommended servers | /v1/servers/recommendations | no |
All under https://api.nordvpn.com. The useful filters for recommendations, URL-encoded, are filters[servers_technologies][identifier]=wireguard_udp, filters[country_id]=<id> and limit=<n>.