API Documentation

Signing Requests

How to make the HMAC signature of the Auth API, step by step, with curl, Node, Python, PHP and Go examples.

Documentation Pages

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

  1. 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 header Date.
  2. Build the canonical string from the seven lines below, joined with \n, with no newline at the end.
  3. Compute the HMAC-SHA512 of that string with the secret key as the key, written as lowercase hexadecimal.
  4. Send the header Authorization: Basic base64(ikey + ":" + signature).

The Canonical String

LineContentExample
1The date, exactly as in the Date header.Sun, 27 Sep 2026 08:15:30 -0000
2HTTP method, upper case.POST
3API hostname, lower case, without https:// and without slashes.auth.wiracode.com
4Path./auth/v2/preauth
5Query string parameters, sorted by name and encoded (see below). Empty when there are none; always empty for POST.txid=3f2b8c1e-5d4a-4e6f-9a7b-1c2d3e4f5a6b
6SHA-512 (hex) of the request body exactly as sent. GET: SHA-512 of the empty string.cf83e1357eefb8bd…
7SHA-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):

ValueContent
ikeyDI9ZQX2M4K7W1T0R5Y8B
skeyq8JH2nV7xTz4LmC9pWd3Rk6YbF1sGe5UaN0oKi2E
DateSun, 27 Sep 2026 08:15:30 -0000
RequestPOST https://auth.wiracode.com/auth/v2/preauth
Body{"username":"rina@example.com"}
Canonical string
Sun, 27 Sep 2026 08:15:30 -0000
POST
auth.wiracode.com
/auth/v2/preauth

2aed91dbfbf8d7ed3d06c220a03720fe91110b7e01e773bfe0ff7f993715fbd07113b57a48430388121beef81f7e6b6a153ab1b0d33625a02121b37bd37ea4ad
cf83e1357eefb8bdf1542850d66d8007d620e4050b5715dc83f4a921d36ce9ce47d0d13c5d85f2b0ff8318d2877eec2f63b931bd47417a81a538327af927da3e
Signature (HMAC-SHA512, hex)
22280779a61e49291168288a0aa4cf80b4129b0ee01fda17250e80c4a42f655cb5d55df3521f92d8981648997242ee1a7f94b0b85646072b3017dd390087fa86
The request as sent
POST /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

curl + 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.

Node.js (duo.mjs)
// 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", ...}
}
Node.js (check.mjs)
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

Python 3 (standard library)
"""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

Python (duo_client)
"""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 8 (curl)
<?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

Go (standard library)
// 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:

LanguageLibraryExample
Pythonduo_clientduo_client.Auth(ikey, skey, host="auth.wiracode.com")
Javaduo-clientnew Http.HttpBuilder("POST", "auth.wiracode.com", "/auth/v2/preauth").build()
Node.js@duosecurity/duo_apinew duo_api.Client(ikey, skey, "auth.wiracode.com")
PHPduosecurity/duo_api_phpnew DuoAPI\Auth($ikey, $skey, "auth.wiracode.com")
Goduo_api_golangduoapi.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 without Content-Type: application/json or application/x-www-form-urlencoded.

Duo is a trademark of Cisco. WiraPass is not affiliated with Duo or Cisco and only offers API compatibility.