Every Auth API call except ping is signed with the secret key. It works like Duo's signing, so Duo's official libraries work as they are.
In Short
- Take the current time in RFC 2822 format, for example
Sun, 27 Sep 2026 08:15:30 -0000. Send exactly the same string in the headerDate. - Build the canonical string from the seven lines below, joined with
\n, with no newline at the end. - Compute the HMAC-SHA512 of that string with the secret key as the key, written as lowercase hexadecimal.
- Send the header
Authorization: Basic base64(ikey + ":" + signature).
The Canonical String
| Line | Content | Example |
|---|---|---|
| 1 | The date, exactly as in the Date header. | Sun, 27 Sep 2026 08:15:30 -0000 |
| 2 | HTTP method, upper case. | POST |
| 3 | API hostname, lower case, without https:// and without slashes. | auth.wiracode.com |
| 4 | Path. | /auth/v2/preauth |
| 5 | Query string parameters, sorted by name and encoded (see below). Empty when there are none; always empty for POST. | txid=3f2b8c1e-5d4a-4e6f-9a7b-1c2d3e4f5a6b |
| 6 | SHA-512 (hex) of the request body exactly as sent. GET: SHA-512 of the empty string. | cf83e1357eefb8bd… |
| 7 | SHA-512 (hex) of the signed X-Duo-* headers. Without such headers: SHA-512 of the empty string. | cf83e1357eefb8bd… |
Encoding Parameters
Names and values are turned into UTF-8, then every byte other than the letters A to Z, a to z, digits and - . _ ~ becomes %XX in upper-case hex. A space becomes %20 (not +), and @ becomes %40. The nama=nilai pairs are sorted by name and joined with &.
X-Duo-* Headers
Extra headers whose name starts with X-Duo- are signed too: names are lower-cased and sorted, then names and values are joined with zero bytes: name1\0value1\0name2\0value2. Most integrations send none.
A Worked Example
With these values (examples only, not real keys):
| Value | Content |
|---|---|
| ikey | DI9ZQX2M4K7W1T0R5Y8B |
| skey | q8JH2nV7xTz4LmC9pWd3Rk6YbF1sGe5UaN0oKi2E |
| Date | Sun, 27 Sep 2026 08:15:30 -0000 |
| Request | POST https://auth.wiracode.com/auth/v2/preauth |
| Body | {"username":"rina@example.com"} |
Sun, 27 Sep 2026 08:15:30 -0000
POST
auth.wiracode.com
/auth/v2/preauth
2aed91dbfbf8d7ed3d06c220a03720fe91110b7e01e773bfe0ff7f993715fbd07113b57a48430388121beef81f7e6b6a153ab1b0d33625a02121b37bd37ea4ad
cf83e1357eefb8bdf1542850d66d8007d620e4050b5715dc83f4a921d36ce9ce47d0d13c5d85f2b0ff8318d2877eec2f63b931bd47417a81a538327af927da3e22280779a61e49291168288a0aa4cf80b4129b0ee01fda17250e80c4a42f655cb5d55df3521f92d8981648997242ee1a7f94b0b85646072b3017dd390087fa86POST /auth/v2/preauth HTTP/1.1
Host: auth.wiracode.com
Date: Sun, 27 Sep 2026 08:15:30 -0000
Content-Type: application/json
Authorization: Basic REk5WlFYMk00SzdXMVQwUjVZOEI6MjIyODA3NzlhNjFlNDkyOTExNjgyODhhMGFhNGNmODBiNDEyOWIwZWUwMWZkYTE3MjUwZTgwYzRhNDJmNjU1Y2I1ZDU1ZGYzNTIxZjkyZDg5ODE2NDg5OTcyNDJlZTFhN2Y5NGIwYjg1NjQ2MDcyYjMwMTdkZDM5MDA4N2ZhODY=
{"username":"rina@example.com"}Signature Versions 2 and 4
WiraPass also accepts Duo's signature version 2, which the Go and Node libraries still use by default. It has only five lines (date, method, host, path, parameters). POST parameters are sent as a form (application/x-www-form-urlencoded) and listed on line five, with HMAC-SHA512 or HMAC-SHA1. Version 4 (without line seven) is accepted too. Version 1, which carries no date, is refused.
Code Examples
Each example calls check and then preauth and prints the answers. Replace the ikey, the skey and the email with your own.
Curl and OpenSSL
#!/usr/bin/env bash
# WiraPass Auth API from the shell: bash, curl and openssl 1.1 or newer.
set -euo pipefail
IKEY="DIXXXXXXXXXXXXXXXXXX"
SKEY="your-secret-key-from-the-operator"
HOST="auth.wiracode.com"
sha512() { openssl dgst -sha512 -r | cut -d' ' -f1; }
# duo_call METHOD PATH [QUERY] [JSON_BODY]
# QUERY must already be canonical: key=value pairs sorted by key and
# encoded with RFC 3986 (a space is %20).
duo_call() {
local method=$1 path=$2 query=${3:-} body=${4:-}
local date canon sig
date=$(LC_ALL=C date -u '+%a, %d %b %Y %H:%M:%S -0000')
canon=$(printf '%s\n%s\n%s\n%s\n%s\n%s\n%s' "$date" "$method" "$HOST" "$path" "$query" \
"$(printf '%s' "$body" | sha512)" "$(printf '' | sha512)")
sig=$(printf '%s' "$canon" | openssl dgst -sha512 -hmac "$SKEY" -r | cut -d' ' -f1)
if [ "$method" = POST ]; then
curl -sS -X POST "https://$HOST$path" -u "$IKEY:$sig" -H "Date: $date" \
-H 'Content-Type: application/json' --data-binary "$body"
else
curl -sS "https://$HOST$path${query:+?$query}" -u "$IKEY:$sig" -H "Date: $date"
fi
echo
}
duo_call GET /auth/v2/check
duo_call POST /auth/v2/preauth '' '{"username":"rina@example.com"}'
Node.js
Save the function as duo.mjs and use it from another file.
// WiraPass Auth API from Node.js 18 or newer, no dependencies.
import { createHash, createHmac } from 'node:crypto';
const IKEY = 'DIXXXXXXXXXXXXXXXXXX';
const SKEY = 'your-secret-key-from-the-operator';
const HOST = 'auth.wiracode.com';
// RFC 3986: everything except A-Z a-z 0-9 - . _ ~ is percent-encoded.
const enc = (s) => encodeURIComponent(s).replace(/[!'()*]/g, (c) => '%' + c.charCodeAt(0).toString(16).toUpperCase());
const sha512 = (s) => createHash('sha512').update(s).digest('hex');
export async function duoCall(method, path, params = {}) {
const date = new Date().toUTCString(); // "Sun, 27 Sep 2026 08:15:30 GMT"
const query = method === 'GET'
? Object.keys(params).sort().map((k) => `${enc(k)}=${enc(params[k])}`).join('&')
: '';
const body = method === 'GET' ? '' : JSON.stringify(params);
const canon = [date, method, HOST.toLowerCase(), path, query, sha512(body), sha512('')].join('\n');
const sig = createHmac('sha512', SKEY).update(canon).digest('hex');
const res = await fetch(`https://${HOST}${path}${query ? `?${query}` : ''}`, {
method,
headers: {
Date: date,
Authorization: 'Basic ' + Buffer.from(`${IKEY}:${sig}`).toString('base64'),
...(method === 'GET' ? {} : { 'Content-Type': 'application/json' }),
},
body: method === 'GET' ? undefined : body,
});
return res.json(); // {"stat": "OK", "response": ...} or {"stat": "FAIL", ...}
}
import { duoCall } from './duo.mjs';
console.log(JSON.stringify(await duoCall('GET', '/auth/v2/check')));
console.log(JSON.stringify(await duoCall('POST', '/auth/v2/preauth', { username: 'rina@example.com' })));
Python Without Libraries
"""WiraPass Auth API from Python 3, standard library only."""
import base64
import email.utils
import hashlib
import hmac
import json
import urllib.error
import urllib.parse
import urllib.request
IKEY = "DIXXXXXXXXXXXXXXXXXX"
SKEY = "your-secret-key-from-the-operator"
HOST = "auth.wiracode.com"
def duo_call(method, path, params=None):
params = params or {}
date = email.utils.formatdate() # "Sun, 27 Sep 2026 08:15:30 -0000"
if method == "GET":
query = "&".join(
"%s=%s" % (urllib.parse.quote(k, "~"), urllib.parse.quote(v, "~"))
for k, v in sorted(params.items()))
body = b""
else:
query = ""
body = json.dumps(params, separators=(",", ":"), sort_keys=True).encode()
canon = "\n".join([date, method, HOST.lower(), path, query,
hashlib.sha512(body).hexdigest(),
hashlib.sha512(b"").hexdigest()])
sig = hmac.new(SKEY.encode(), canon.encode(), hashlib.sha512).hexdigest()
auth = base64.b64encode(("%s:%s" % (IKEY, sig)).encode()).decode()
url = "https://%s%s%s" % (HOST, path, "?" + query if query else "")
req = urllib.request.Request(url, data=body if method != "GET" else None, method=method)
req.add_header("Date", date)
req.add_header("Authorization", "Basic " + auth)
if method != "GET":
req.add_header("Content-Type", "application/json")
try:
with urllib.request.urlopen(req) as res:
return json.load(res)
except urllib.error.HTTPError as e:
return json.load(e) # {"stat": "FAIL", "code": ..., "message": ...}
print(json.dumps(duo_call("GET", "/auth/v2/check")))
print(json.dumps(duo_call("POST", "/auth/v2/preauth", {"username": "rina@example.com"})))
Python with Duo's Official Library
"""WiraPass with Duo's official Python library: pip install duo_client"""
import duo_client
auth = duo_client.Auth(
ikey="DIXXXXXXXXXXXXXXXXXX",
skey="your-secret-key-from-the-operator",
host="auth.wiracode.com",
)
print(auth.check())
print(auth.preauth(username="rina@example.com"))
PHP
<?php
// WiraPass Auth API from PHP 8 with the curl extension, no libraries.
const IKEY = 'DIXXXXXXXXXXXXXXXXXX';
const SKEY = 'your-secret-key-from-the-operator';
const HOST = 'auth.wiracode.com';
function duo_call(string $method, string $path, array $params = []): array
{
$date = gmdate('D, d M Y H:i:s') . ' -0000';
$query = '';
$body = '';
if ($method === 'GET') {
ksort($params);
$pairs = [];
foreach ($params as $k => $v) {
$pairs[] = rawurlencode($k) . '=' . rawurlencode($v);
}
$query = implode('&', $pairs);
} else {
$body = json_encode((object) $params);
}
$canon = implode("\n", [$date, $method, strtolower(HOST), $path, $query,
hash('sha512', $body), hash('sha512', '')]);
$sig = hash_hmac('sha512', $canon, SKEY);
$headers = ['Date: ' . $date, 'Authorization: Basic ' . base64_encode(IKEY . ':' . $sig)];
$ch = curl_init('https://' . HOST . $path . ($query !== '' ? '?' . $query : ''));
if ($method !== 'GET') {
$headers[] = 'Content-Type: application/json';
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
}
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_HTTPHEADER => $headers,
CURLOPT_RETURNTRANSFER => true,
]);
return json_decode(curl_exec($ch), true);
}
echo json_encode(duo_call('GET', '/auth/v2/check')), "\n";
echo json_encode(duo_call('POST', '/auth/v2/preauth', ['username' => 'rina@example.com'])), "\n";
Go
// WiraPass Auth API from Go, standard library only.
package main
import (
"bytes"
"crypto/hmac"
"crypto/sha512"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"sort"
"strings"
"time"
)
const (
ikey = "DIXXXXXXXXXXXXXXXXXX"
skey = "your-secret-key-from-the-operator"
host = "auth.wiracode.com"
)
func sha512Hex(b []byte) string {
sum := sha512.Sum512(b)
return hex.EncodeToString(sum[:])
}
// RFC 3986: url.QueryEscape, but a space is %20 and not "+".
func enc(s string) string { return strings.ReplaceAll(url.QueryEscape(s), "+", "%20") }
func duoCall(method, path string, params map[string]string) (string, error) {
date := time.Now().UTC().Format(time.RFC1123Z) // "Sun, 27 Sep 2026 08:15:30 +0000"
query, body := "", []byte{}
if method == http.MethodGet {
keys := make([]string, 0, len(params))
for k := range params {
keys = append(keys, k)
}
sort.Strings(keys)
pairs := make([]string, 0, len(keys))
for _, k := range keys {
pairs = append(pairs, enc(k)+"="+enc(params[k]))
}
query = strings.Join(pairs, "&")
} else {
if params == nil {
params = map[string]string{}
}
body, _ = json.Marshal(params) // keys come out sorted
}
canon := strings.Join([]string{date, method, strings.ToLower(host), path, query, sha512Hex(body), sha512Hex(nil)}, "\n")
mac := hmac.New(sha512.New, []byte(skey))
mac.Write([]byte(canon))
sig := hex.EncodeToString(mac.Sum(nil))
u := "https://" + host + path
if query != "" {
u += "?" + query
}
var reader io.Reader
if method != http.MethodGet {
reader = bytes.NewReader(body)
}
req, err := http.NewRequest(method, u, reader)
if err != nil {
return "", err
}
req.Header.Set("Date", date)
req.Header.Set("Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte(ikey+":"+sig)))
if method != http.MethodGet {
req.Header.Set("Content-Type", "application/json")
}
res, err := http.DefaultClient.Do(req)
if err != nil {
return "", err
}
defer res.Body.Close()
out, err := io.ReadAll(res.Body)
return string(out), err
}
func main() {
for _, c := range []struct {
method, path string
params map[string]string
}{
{"GET", "/auth/v2/check", nil},
{"POST", "/auth/v2/preauth", map[string]string{"username": "rina@example.com"}},
} {
out, err := duoCall(c.method, c.path, c.params)
if err != nil {
panic(err)
}
fmt.Println(out)
}
}
Duo's Official Libraries
Duo's official libraries sign by themselves, so you only set the API hostname, the ikey and the skey:
| Language | Library | Example |
|---|---|---|
| Python | duo_client | duo_client.Auth(ikey, skey, host="auth.wiracode.com") |
| Java | duo-client | new Http.HttpBuilder("POST", "auth.wiracode.com", "/auth/v2/preauth").build() |
| Node.js | @duosecurity/duo_api | new duo_api.Client(ikey, skey, "auth.wiracode.com") |
| PHP | duosecurity/duo_api_php | new DuoAPI\Auth($ikey, $skey, "auth.wiracode.com") |
| Go | duo_api_golang | duoapi.NewDuoApi(ikey, skey, "auth.wiracode.com", "my-app") |
If a library refuses the TLS certificate, read the CA pinning section.
Common Mistakes
40103: the host line has https://, upper case, or a port that is not sent. Write the hostname exactly as in the URL, in lower case.40103: spaces encoded as + (form style), while the canonical string needs %20.40103: the HTTP library re-serialises the JSON after signing. Sign the exact bytes you send.40104,40105: the Date header is missing, is not RFC 2822, or the server clock is more than 5 minutes off.40106: a POST withoutContent-Type: application/jsonorapplication/x-www-form-urlencoded.