Oracle APEX in Docker: Oracle Wallet Script for HTTPS (ORA-29024 Fix)

Oracle APEX PL/SQL Docker Security Open Source

Oracle Wallet in Docker — One Script That Makes HTTPS Calls From Oracle APEX and PL/SQL Just Work

A free, MIT-licensed bash script that fetches the live certificate chain, completes it up to the root CA and builds an auto-login wallet inside your Oracle container — so ORA-29024, ORA-24247 and ORA-28759 stop being your evening plans.

📅 October 2026 👤 Sajjad Hanifa ⏱ ~18 min read 🏢 S&H Software Solutions
Cover: One script. Oracle Wallet in Docker. HTTPS from APEX just works. ORA-29024 turns into HTTP 200 — with a real terminal run of setup_oracle_wallet.sh

You call an HTTPS endpoint from Oracle APEX — a payment provider, a WebDAV drive, your own n8n webhook — and the database answers with ORA-29273: HTTP request failed. Two lines further down the stack sits the real reason: ORA-29024: Certificate validation failure. Your browser opens the same URL without complaint, curl on the host is happy, and the database still says no.

Terminal window: SQL*Plus 23.26.3 connected to Oracle AI Database 26ai Free runs select utl_http.request('https://api.example.com/') and fails with ORA-29273 HTTP request failed and ORA-29024 Certificate validation failure, because no Oracle wallet exists yet.
Real runFigure 1 — The starting point, reproduced: a plain UTL_HTTP.REQUEST from SQL*Plus inside the gvenzl/oracle-free:23 container — ORA-29273, and two lines further down the real reason, ORA-29024.

The reason is simple once you know it: Oracle does not use the operating system's trust store. UTL_HTTP and APEX_WEB_SERVICE trust exactly the certificates inside an Oracle wallet — and when the database runs in a Docker container, nobody creates that wallet for you. Fix it, and the next wall is ORA-24247 from the network ACL. Fix that, and the wallet you built as root answers with ORA-28759: failure to open file.

I have built these wallets by hand on every Oracle container I have touched: a dozen orapki commands, .crt files copied around with docker cp, and at least once a chmod -R 777 at eleven in the evening because "it works now". This post replaces all of that with one script you run on the Docker host, plus the SQL that goes with it. It is the reference I wanted for myself — every line of code is in this post, and every error message below was reproduced on a real container, not copied from a forum thread.

Who it is for: anyone running Oracle Database and APEX in Docker or Podman who needs outbound HTTPS. What you have at the end: a wallet on a persistent volume, the network ACL, a tested UTL_HTTP and APEX_WEB_SERVICE call, and a cron job that keeps the certificates current.

Free and open source. Complete code in this post — copy it from Step 3. MIT licensed. Two files: setup_oracle_wallet.sh (Step 3) and wallet_acl_test.sql (Steps 4–7). Originally developed by S&H Software Solutions – first published on APEX Note (October 2026).

Why the Database Says No — Four Gates Between APEX and the API

Every HTTPS call from PL/SQL passes four checks, always in the same order. Each one fails with its own error number, and knowing which gate you are stuck at is worth more than any amount of trial and error:

Architecture: an HTTPS call from Oracle APEX passes four gates inside the Docker container APEX or PL/SQL calls UTL_HTTP or APEX_WEB_SERVICE. Inside the container the call passes the network ACL (ORA-24247), opening the wallet (ORA-28759), the certificate chain check (ORA-29024) and the host name check (ORA-24263). The wallet lives on the volume under /opt/oracle/oradata. Only then does the TLS connection reach api.example.com on port 443. Oracle APEX page · PL/SQL DOCKER CONTAINER · oracle-db UTL_HTTP · APEX_WEB_SERVICE 1 · Network ACL host:443 granted to this schema? ORA-24247 2 · Open the wallet cwallet.sso readable by user oracle? ORA-28759 3 · Certificate chain ends at a CA certificate in the wallet? ORA-29024 4 · Host name certificate matches the host in the URL? ORA-24263 Volume /opt/oracle/oradata wallet/https_wallet · read by 2 + 3 TLS handshake with SNI api.example.com : 443 sends leaf + intermediate
Figure 2 — Four gates between your PL/SQL and the API. The script handles gates 2 and 3, the ACL handles gate 1, gate 4 is about calling the right name.
#GateWhat Oracle checksError when it failsFixed by
1Network ACLIs this schema allowed to open a connection to host:443?ORA-24247Step 04 (SQL)
2WalletCan the server process (user oracle) open the wallet files?ORA-28759Step 03 (script)
3Certificate chainDoes the server's chain end at a CA certificate in the wallet?ORA-29024Step 03 (script)
4Host nameDoes the certificate match the host in the URL?ORA-24263Calling the right name

By default UTL_HTTP wraps every one of them in ORA-29273: HTTP request failed, which is why the first error you read is always the least useful one. One call — UTL_HTTP.SET_DETAILED_EXCP_SUPPORT(TRUE) — changes that, and every test block in this post uses it.


What the Script Does

The rule I set myself: the script runs on the host, knows nothing about your application, and can be re-run every night without changing anything that is already correct.

  • Fetches the live chain from one or more hosts with openssl s_client -showcerts -servername — with SNI, so a shared server returns the certificate you will actually get
  • Completes incomplete chains — servers almost never send their root CA, and some forget the intermediate. The script finds the missing issuers in the host trust store or downloads them from the certificate's AIA URL
  • Verifies before it touches anything — openssl verify against exactly the certificates that will end up in the wallet, with the host trust store switched off
  • Imports roots and intermediates, not the server certificate — so the wallet survives every 90-day Let's Encrypt renewal
  • Also takes files and URLs — .crt, .pem, .der, .p7b, for private CAs and TLS-inspecting company proxies
  • Idempotent — creates the auto-login wallet once, skips certificates that are already there (PKI-04003), and backs up the old wallet before --recreate
  • Works on images without Java — community images ship orapki but no JDK; the script then uses a throwaway JRE container with Oracle's own oraclepki.jar from Maven Central
  • Least privilege — oracle:oinstall, folder 750, files 600, and it refuses to finish if the result is anything else
  • Never puts the password on a command line — environment variable, password file, prompt, or no password at all with --sso-only
  • Warns early — when the wallet path is not on a volume, when the certificate name does not match the host, and when a certificate expires within 14 days
Tested on 6 October 2026 with Oracle AI Database 26ai Free 23.26.3 (gvenzl/oracle-free:23 and :23-slim), Oracle APEX 26.2, Docker 29, OpenSSL 3.0 and bash 5 — against a test CA whose server sends only its own certificate, a host-name mismatch, a wrong password, wrong file ownership and a TLS-inspecting egress proxy. The orapki commands and the PL/SQL APIs used here exist in 19c and 21c as well; on those versions the script uses the orapki of the database home when it can run. All screenshots in this post come from a fresh re-run on 7 October 2026 (same versions, plus ORDS 26.3 for the APEX pages).

Step-by-Step: From Container to First HTTPS Call

01
Step one

Know Your Container and Its Volume

Docker host · 2 commands · 1 minute

The wallet has to live inside the container, because that is where the database server process opens it. And it has to live on a volume, because everything else in a container is gone after the next docker compose pull && docker compose up -d. Two commands tell you the container name and where its volume is mounted:

Bashon the docker host
docker ps --format 'table {{.Names}}\t{{.Image}}\t{{.Status}}'

docker inspect -f '{{range .Mounts}}{{.Source}} -> {{.Destination}}{{println}}{{end}}' oracle-db

On the official images from container-registry.oracle.com and on the popular gvenzl/oracle-free images, the data volume is mounted at /opt/oracle/oradata. That is why the script's default wallet location is /opt/oracle/oradata/wallet/<name> — next to the data files, on the volume, and still there after the container has been re-created. While testing I also ran into two things about the images themselves that you should know before you start:

Imageorapki usable inside?APEX installable?
container-registry.oracle.com/database/free, …/enterpriseFull Oracle home — the script uses it directlyYes
gvenzl/oracle-free:23 (tested)No — the orapki script is there, but no JDK and no jlib. The script falls back to a JRE containerYes (tested, APEX 26.2)
gvenzl/oracle-free:23-slim (tested)No — and no openssl either, which is why the chain is fetched on the hostNo — XDB is removed, apexins.sql stops at the prerequisite check
02
Step two

Dry Run — See the Chain Before You Import It

-n check mode · changes nothing · needs only openssl

Put setup_oracle_wallet.sh on the Docker host — not into the container — and start with check mode. It fetches, completes and verifies the chain, prints what it would import, and does not touch the container at all. That makes it the first thing I run whenever an integration breaks:

Bashdry run
chmod 750 setup_oracle_wallet.sh
./setup_oracle_wallet.sh -n -H api.example.com -H webdav.hidrive.strato.com

Below is a real run against my test server. I configured it on purpose like a surprising number of production servers: it sends only its own certificate — no intermediate, no root:

Output./setup_oracle_wallet.sh -n -H api.example.comreal run · check mode
==> Checking the host
  OK    OpenSSL 3.0.13 found

==> Collecting certificates
  Fetching live chain from api.example.com ...
  Server certificate : api.example.com  (expires Jan  5 17:44:52 2027 GMT)
  Chain sent         : 1 certificate(s)
  OK    Certificate name matches api.example.com (SNI ok)

==> Completing the chain up to the root CA
  + issuer of 'api.example.com' -> 'APEX Note Test Issuing CA'
  + issuer of 'APEX Note Test Issuing CA' -> 'APEX Note Test Root CA'

==> Verifying the chain offline (exactly what the wallet will contain)
  OK    api.example.com: chain verifies against the wallet content

  ROLE          SUBJECT (CN)                                 EXPIRES
  root          APEX Note Test Root CA                       Oct  3 23:20:55 2036 GMT
  intermediate  APEX Note Test Issuing CA                    Oct  5 23:20:55 2031 GMT
  leaf (skip)   api.example.com                              Jan  5 17:44:52 2027 GMT

==> Check mode: nothing was changed.

The two + issuer of lines are the interesting part. The server delivered one certificate; the script followed the Authority Information Access URL inside it to download the intermediate, then did the same again for the root, and only then ran openssl verify — against nothing but those two certificates. A browser does that AIA dance silently. Oracle does not, and I proved it with this very server: a wallet containing only the root still failed with ORA-29024, because the intermediate was missing.

03
Step three

Build the Wallet

setup_oracle_wallet.sh · full source · ~1 minute

First the password. An auto-login wallet still has a password — it protects ewallet.p12, the file you edit with orapki — but the database never needs it. The script never accepts it as an argument, because arguments end up in ps output and shell history. It looks in this order:

SourceUse it for
WALLET_PASSWORD environment variableCI pipelines with a secret store
WALLET_PASSWORD_FILE — a file with mode 600Cron jobs and repeatable runs (my default)
Interactive promptThe first run in a terminal — asks twice for a new wallet
--sso-onlyNo password at all — cwallet.sso only (see "How it works inside")
orapki 23ai/26ai wants 16 characters. The 19c tool accepted eight; the current one stops with PKI-01002: Invalid password. Passwords must have a minimum length of sixteen characters…. The script checks the stricter rule up front, so you find out before anything is created.
Bashfirst real run
# once: a random password in a file only root can read
install -m 600 /dev/null /root/.oracle_wallet_pw
openssl rand -base64 24 > /root/.oracle_wallet_pw

WALLET_PASSWORD_FILE=/root/.oracle_wallet_pw \
  ./setup_oracle_wallet.sh -c oracle-db -w https_wallet \
      -H api.example.com -H webdav.hidrive.strato.com

Here is the complete script. It is long because it handles the cases that cost me evenings — not because the happy path is complicated. The host part collects and verifies the certificates; everything between <<'INNER' and INNER runs inside the container. The quotes around 'INNER' matter: they stop the host shell from expanding a single $ in that block, so the inner script sees exactly what is written here.

Bashsetup_oracle_wallet.shcomplete · 598 lines
#!/usr/bin/env bash
# =============================================================================
#  setup_oracle_wallet.sh
#  Create or update an Oracle wallet INSIDE a Docker container so that
#  UTL_HTTP and APEX_WEB_SERVICE can call HTTPS endpoints (fixes ORA-29024).
#
#  Runs on the Docker HOST. Fetches the live certificate chain of one or more
#  target hosts (and/or takes .crt files / URLs), completes the chain up to the
#  root CA, verifies it with openssl, and imports root + intermediates into an
#  auto-login wallet with orapki inside the container. Safe to re-run.
#
#  Originally developed by S&H Software Solutions (Sajjad Hanifa)
#  First published on APEX Note - https://www.apexnote.de - October 2026
#  License: MIT - complete code in the blog post on www.apexnote.de
# =============================================================================
set -Eeuo pipefail

readonly VERSION="1.0.0"

# ---------------------------------------------------------------- defaults ---
# Every default can be overridden by an environment variable or an argument.
CONTAINER="${ORA_CONTAINER:-oracle-db}"
WALLET_NAME="${WALLET_NAME:-https_wallet}"
WALLET_BASE="${WALLET_BASE:-/opt/oracle/oradata/wallet}"
WALLET_OWNER="${WALLET_OWNER:-oracle:oinstall}"
PROXY="${WALLET_PROXY:-}"            # host:port, only for fetching the chain
TOOL_IMAGE="${WALLET_TOOL_IMAGE:-}"  # helper image when the DB image cannot run orapki
JRE_IMAGE="${WALLET_JRE_IMAGE:-eclipse-temurin:21-jre}"
PKI_JAR_VERSION="${ORAPKI_JAR_VERSION:-23.26.3.0.0}"
PKI_MAVEN="${ORAPKI_MAVEN:-https://repo1.maven.org/maven2/com/oracle/database/security}"
PKI_CACHE="${ORAPKI_CACHE:-${XDG_CACHE_HOME:-$HOME/.cache}/setup_oracle_wallet}"
CONNECT_TIMEOUT="${CONNECT_TIMEOUT:-15}"

HOSTS=()          # -H  host[:port]  (repeatable)
CERT_FILES=()     # -f  local .crt/.pem/.cer/.der/.p7b on the host (repeatable)
CERT_URLS=()      # -u  URL of a certificate file (repeatable)
RECREATE=0        # -r  back up and rebuild the wallet from scratch
SSO_ONLY=0        # -s  password-less wallet (cwallet.sso only)
INCLUDE_LEAF=0    # -l  also import the server (leaf) certificates
CHECK_ONLY=0      # -n  fetch + verify only, do not touch the container

# ---------------------------------------------------------------- logging ----
if [[ -t 1 ]]; then
  C_R=$'\e[31m'; C_Y=$'\e[33m'; C_G=$'\e[32m'; C_B=$'\e[1m'; C_0=$'\e[0m'
else
  C_R=""; C_Y=""; C_G=""; C_B=""; C_0=""
fi
info() { printf '%s\n' "  $*"; }
ok()   { printf '%s\n' "${C_G}  OK${C_0}    $*"; }
warn() { printf '%s\n' "${C_Y}  WARN${C_0}  $*" >&2; }
die()  { printf '%s\n' "${C_R}  ERROR${C_0} $*" >&2; exit 1; }
step() { printf '\n%s\n' "${C_B}==> $*${C_0}"; }

usage() {
  cat <<'USAGE'
Usage: setup_oracle_wallet.sh [options] -H host[:port] [-H ...] [-f file] [-u url]

Builds an auto-login Oracle wallet inside a running Docker container and
imports the CA certificates (root + intermediates) needed to call HTTPS hosts
from UTL_HTTP / APEX_WEB_SERVICE.

Certificate sources (at least one, can be combined and repeated):
  -H, --host HOST[:PORT]   fetch the live chain from HOST (default port 443)
  -f, --cert-file FILE     import a certificate/bundle file from the host
  -u, --cert-url URL       download a certificate/bundle and import it

Options:
  -c, --container NAME     Docker container             (default: oracle-db)
  -w, --wallet NAME        wallet folder name           (default: https_wallet)
  -b, --base PATH          wallet base path in container
                           (default: /opt/oracle/oradata/wallet)
  -o, --owner USER:GROUP   owner of the wallet files    (default: oracle:oinstall)
  -p, --proxy HOST:PORT    HTTP proxy for fetching the chain (openssl -proxy)
  -T, --tool-image IMAGE   run orapki in a throwaway container of IMAGE that
                           shares the volumes of the DB container. Used
                           automatically (eclipse-temurin:21-jre + oraclepki.jar
                           from Maven Central) when the DB image has no Java,
                           e.g. gvenzl/oracle-free or gvenzl/oracle-xe
  -s, --sso-only           password-less wallet (orapki -auto_login_only)
  -l, --include-leaf       also import the server certificates (not recommended)
  -r, --recreate           back up the existing wallet and build a new one
  -n, --check              fetch and verify the chain only, change nothing
  -h, --help               show this help
  -V, --version            show the version

Wallet password (not needed with --sso-only), first match wins:
  WALLET_PASSWORD          environment variable
  WALLET_PASSWORD_FILE     file containing the password (chmod 600)
  interactive prompt       when running in a terminal

Examples:
  ./setup_oracle_wallet.sh -c oracle-db -H api.example.com
  WALLET_PASSWORD_FILE=/root/.oracle_wallet_pw ./setup_oracle_wallet.sh \
      -c oracle-db -w https_wallet -H api.example.com -H webdav.hidrive.strato.com
  ./setup_oracle_wallet.sh -c oracle-db -s -f ./corporate-root-ca.crt
  ./setup_oracle_wallet.sh -n -H api.example.com      # dry run
  ./setup_oracle_wallet.sh -c oracle-db -T container-registry.oracle.com/database/free -H api.example.com
USAGE
}

# ---------------------------------------------------------------- arguments --
need_arg() { [[ $# -ge 2 && -n "$2" && "$2" != -* ]] || die "Option $1 needs a value."; }

while [[ $# -gt 0 ]]; do
  case "$1" in
    -H|--host)         need_arg "$@"; HOSTS+=("$2"); shift 2 ;;
    -f|--cert-file)    need_arg "$@"; CERT_FILES+=("$2"); shift 2 ;;
    -u|--cert-url)     need_arg "$@"; CERT_URLS+=("$2"); shift 2 ;;
    -c|--container)    need_arg "$@"; CONTAINER="$2"; shift 2 ;;
    -w|--wallet)       need_arg "$@"; WALLET_NAME="$2"; shift 2 ;;
    -b|--base)         need_arg "$@"; WALLET_BASE="${2%/}"; shift 2 ;;
    -o|--owner)        need_arg "$@"; WALLET_OWNER="$2"; shift 2 ;;
    -p|--proxy)        need_arg "$@"; PROXY="$2"; shift 2 ;;
    -T|--tool-image)   need_arg "$@"; TOOL_IMAGE="$2"; shift 2 ;;
    -s|--sso-only)     SSO_ONLY=1; shift ;;
    -l|--include-leaf) INCLUDE_LEAF=1; shift ;;
    -r|--recreate)     RECREATE=1; shift ;;
    -n|--check)        CHECK_ONLY=1; shift ;;
    -h|--help)         usage; exit 0 ;;
    -V|--version)      echo "setup_oracle_wallet.sh ${VERSION}"; exit 0 ;;
    *)                 usage >&2; die "Unknown argument: $1" ;;
  esac
done

(( ${#HOSTS[@]} + ${#CERT_FILES[@]} + ${#CERT_URLS[@]} > 0 )) \
  || { usage >&2; die "Give at least one -H host, -f file or -u url."; }

# Only harmless characters: the values end up in paths inside the container.
[[ "$WALLET_NAME" =~ ^[A-Za-z0-9._-]+$ && "$WALLET_NAME" != *..* ]] \
  || die "Invalid wallet name '$WALLET_NAME' (allowed: A-Z a-z 0-9 . _ -)."
[[ "$WALLET_BASE" =~ ^/[A-Za-z0-9/._-]+$ && "$WALLET_BASE" != *..* ]] \
  || die "Invalid base path '$WALLET_BASE' (absolute path, no '..')."
[[ "$WALLET_OWNER" =~ ^[a-z_][a-z0-9_-]*:[a-z_][a-z0-9_-]*$ ]] \
  || die "Invalid owner '$WALLET_OWNER' (expected user:group)."
[[ "$CONNECT_TIMEOUT" =~ ^[0-9]+$ ]] || die "CONNECT_TIMEOUT must be a number."
WALLET_PATH="${WALLET_BASE}/${WALLET_NAME}"

# ---------------------------------------------------------------- workspace --
WORK="$(mktemp -d "${TMPDIR:-/tmp}/owallet.XXXXXX")"
chmod 700 "$WORK"
mkdir -p "$WORK/set" "$WORK/leaf" "$WORK/import"
cleanup() { rm -rf "$WORK"; unset WALLET_PASSWORD; }
trap cleanup EXIT
trap 'die "Aborted in line $LINENO."' ERR

# ---------------------------------------------------------------- checks -----
step "Checking the host"
for cmd in openssl awk sed; do
  command -v "$cmd" >/dev/null 2>&1 || die "'$cmd' is not installed on the host."
done
if (( ${#CERT_URLS[@]} > 0 )) && ! command -v curl >/dev/null 2>&1; then
  die "'curl' is required for -u."
fi
TIMEOUT_CMD=()
command -v timeout >/dev/null 2>&1 && TIMEOUT_CMD=(timeout "$CONNECT_TIMEOUT")

# openssl verify must not silently fall back to the host trust store.
NO_STORE=(-no-CApath)
openssl version | grep -q '^OpenSSL [3-9]' && NO_STORE+=(-no-CAstore)
ok "$(openssl version | awk '{print $1, $2}') found"

if (( ! CHECK_ONLY )); then
  command -v docker >/dev/null 2>&1 || die "'docker' is not installed on the host."
  docker info >/dev/null 2>&1 || die "Cannot talk to the Docker daemon (are you root / in the docker group?)."
  [[ "$(docker inspect -f '{{.State.Running}}' "$CONTAINER" 2>/dev/null || true)" == "true" ]] \
    || die "Container '$CONTAINER' does not exist or is not running (docker ps)."
  ok "Container '$CONTAINER' is running"

  # The wallet must live on a volume, or it dies with the next 'docker run'.
  on_volume=0
  while IFS= read -r mnt; do
    [[ -n "$mnt" ]] || continue
    if [[ "$WALLET_BASE" == "$mnt" || "$WALLET_BASE" == "${mnt%/}/"* ]]; then on_volume=1; fi
  done < <(docker inspect -f '{{range .Mounts}}{{println .Destination}}{{end}}' "$CONTAINER")
  if (( on_volume )); then
    ok "$WALLET_BASE is on a persistent volume"
  else
    warn "$WALLET_BASE is NOT on a mounted volume - the wallet is lost when the container is re-created."
  fi

  # orapki is a Java tool. Community images (gvenzl/oracle-free, gvenzl/oracle-xe)
  # ship the orapki script but neither a JDK nor $ORACLE_HOME/jlib.
  # shellcheck disable=SC2016  # expanded inside the container, not here
  PKI_PROBE='j="${JAVA_HOME:-/nonexistent}/bin/java"; [ -x "${ORACLE_HOME:-/x}/jdk/bin/java" ] && j="$ORACLE_HOME/jdk/bin/java"; [ -x "$j" ] && [ -f "${ORACLE_HOME:-/x}/jlib/oraclepki.jar" ]'
  USE_JAR=0
  if [[ -z "$TOOL_IMAGE" ]] && docker exec "$CONTAINER" sh -c "$PKI_PROBE"; then
    ok "orapki is usable inside the container"
  else
    if [[ -z "$TOOL_IMAGE" ]]; then
      TOOL_IMAGE="$JRE_IMAGE"
      warn "orapki cannot run inside '$CONTAINER' (no Java/jlib) - using a throwaway $TOOL_IMAGE container instead."
    fi
    (( on_volume )) || die "A tool container needs $WALLET_BASE on a volume (shared via --volumes-from)."
    docker image inspect "$TOOL_IMAGE" >/dev/null 2>&1 \
      || { info "Pulling $TOOL_IMAGE ..."; docker pull -q "$TOOL_IMAGE" >/dev/null || die "Cannot pull $TOOL_IMAGE."; }
    if docker run --rm --network none --entrypoint sh "$TOOL_IMAGE" -c "$PKI_PROBE"; then
      ok "orapki is usable in $TOOL_IMAGE"
    elif docker run --rm --network none --entrypoint sh "$TOOL_IMAGE" -c 'command -v java >/dev/null'; then
      USE_JAR=1
    else
      die "'$TOOL_IMAGE' has neither a usable orapki nor java."
    fi
  fi

  if (( USE_JAR )); then
    # Oracle publishes the orapki classes on Maven Central - no DB home needed.
    # Cached on the host, so cron runs neither depend on nor hammer Maven.
    command -v curl >/dev/null 2>&1 || die "'curl' is required to download oraclepki.jar."
    mkdir -p "$PKI_CACHE" "$WORK/import/jars"
    arts=(oraclepki)                                  # 23.x+: one self-contained jar
    [[ "${PKI_JAR_VERSION%%.*}" =~ ^[0-9]+$ && "${PKI_JAR_VERSION%%.*}" -lt 23 ]] \
      && arts+=(osdt_core osdt_cert)                  # 19c/21c need the osdt jars too
    for art in "${arts[@]}"; do
      file="$art-$PKI_JAR_VERSION.jar"
      url="$PKI_MAVEN/$art/$PKI_JAR_VERSION/$file"
      if [[ ! -s "$PKI_CACHE/$file" || ! -s "$PKI_CACHE/$file.sha1" ]]; then
        code="$(curl -sSL --retry 6 --retry-delay 2 --max-time 60 -w '%{http_code}' \
                 -o "$WORK/$file" "$url" 2>/dev/null || true)"
        [[ "$code" == "200" ]] || die "Cannot download $url (HTTP $code) - try again or set ORAPKI_MAVEN to a mirror."
        curl -fsSL --retry 6 --retry-delay 2 --max-time 30 "$url.sha1" -o "$WORK/$file.sha1" \
          || die "Cannot download $url.sha1"
        mv "$WORK/$file" "$PKI_CACHE/$file"; mv "$WORK/$file.sha1" "$PKI_CACHE/$file.sha1"
      fi
      want="$(cut -c1-40 "$PKI_CACHE/$file.sha1")"
      have="$( (sha1sum "$PKI_CACHE/$file" 2>/dev/null || shasum "$PKI_CACHE/$file") | cut -c1-40)"
      if [[ -z "$want" || "$want" != "$have" ]]; then
        rm -f "$PKI_CACHE/$file" "$PKI_CACHE/$file.sha1"
        die "Checksum mismatch for $file - cache cleared, please re-run."
      fi
      cp "$PKI_CACHE/$file" "$WORK/import/jars/$art.jar"
    done
    ok "oraclepki $PKI_JAR_VERSION from Maven Central (SHA-1 verified, cached in $PKI_CACHE)"
  fi

  # Resolve the owner to numeric ids of the DB container - correct even when
  # the files are written by a different (tool) container.
  OWNER_USER="${WALLET_OWNER%%:*}"; OWNER_GROUP="${WALLET_OWNER#*:}"
  OWNER_UID="$(docker exec "$CONTAINER" id -u "$OWNER_USER" 2>/dev/null)" \
    || die "User '$OWNER_USER' does not exist in '$CONTAINER'."
  OWNER_GID="$(docker exec "$CONTAINER" getent group "$OWNER_GROUP" 2>/dev/null | cut -d: -f3 || true)"
  if [[ -z "$OWNER_GID" ]]; then
    OWNER_GID="$(docker exec "$CONTAINER" id -g "$OWNER_USER")"
    warn "Group '$OWNER_GROUP' not found, using the primary group of '$OWNER_USER' ($OWNER_GID)."
  fi
  ok "Wallet owner: $OWNER_USER ($OWNER_UID:$OWNER_GID)"
fi

# ---------------------------------------------------------------- helpers ----
x509()      { openssl x509 -noout "$@" 2>/dev/null; }
cn_of()     { x509 -subject -nameopt multiline -in "$1" | awk -F' = ' '/commonName/{print $2; exit}'; }
fp_of()     { x509 -fingerprint -sha256 -in "$1" | cut -d= -f2 | tr -d ':'; }
is_self()   { [[ "$(x509 -subject_hash -in "$1")" == "$(x509 -issuer_hash -in "$1")" ]]; }
is_ca()     { x509 -text -in "$1" | grep -q 'CA:TRUE'; }
in_set()    { [[ -f "$WORK/set/$(fp_of "$1").pem" ]]; }

# Split any PEM bundle into one file per certificate: <prefix>01.pem, 02.pem...
split_pem() {
  awk -v p="$2" '
    /-----BEGIN CERTIFICATE-----/ { n++; f = sprintf("%s%02d.pem", p, n) }
    f { print > f }
    /-----END CERTIFICATE-----/   { close(f); f = "" }' "$1"
}

# Normalise PEM / DER / PKCS#7 into a PEM bundle on stdout.
to_pem() {
  if grep -q -- '-----BEGIN CERTIFICATE-----' "$1"; then cat "$1"
  elif openssl x509 -inform DER -in "$1" 2>/dev/null; then :
  elif openssl pkcs7 -inform DER -print_certs -in "$1" 2>/dev/null; then :
  elif openssl pkcs7 -inform PEM -print_certs -in "$1" 2>/dev/null; then :
  else return 1
  fi
}

# Put one certificate into the import set (CA) or the leaf set (server cert).
add_cert() { # $1 = single-cert PEM, $2 = source label
  local fp dst
  x509 -in "$1" >/dev/null || { warn "Skipping unreadable certificate from $2"; return 0; }
  fp="$(fp_of "$1")"
  if is_ca "$1" || is_self "$1" || (( INCLUDE_LEAF )); then
    dst="$WORK/set/$fp.pem"
  else
    dst="$WORK/leaf/$fp.pem"
  fi
  [[ -f "$dst" ]] || openssl x509 -in "$1" -out "$dst"
}

add_bundle() { # $1 = any certificate file, $2 = source label
  local tmp="$WORK/bundle.pem" part
  to_pem "$1" > "$tmp" || die "$2 is not a certificate (PEM, DER or PKCS#7). Is it an HTML login page?"
  rm -f "$WORK"/part-*.pem
  split_pem "$tmp" "$WORK/part-"
  for part in "$WORK"/part-*.pem; do
    [[ -e "$part" ]] || die "No certificate found in $2."
    add_cert "$part" "$2"
  done
}

# Find the issuer of a certificate: host trust store first, then AIA download.
find_issuer() { # $1 = cert, $2 = output file
  local ih dir cand url
  ih="$(x509 -issuer_hash -in "$1")"
  for dir in /etc/ssl/certs /etc/pki/tls/certs /usr/local/share/certs; do
    for cand in "$dir/$ih".[0-9]; do
      [[ -f "$cand" ]] || continue
      if [[ "$(x509 -subject_hash -in "$cand")" == "$ih" ]]; then
        openssl x509 -in "$cand" -out "$2" 2>/dev/null && return 0
      fi
    done
  done
  url="$(x509 -text -in "$1" | awk '/CA Issuers - URI:/{sub(/.*URI:/, ""); print; exit}')"
  if [[ -n "$url" ]] && command -v curl >/dev/null 2>&1; then
    if curl -fsSL --retry 3 --max-time "$CONNECT_TIMEOUT" "$url" -o "$WORK/aia.bin" 2>/dev/null \
       && to_pem "$WORK/aia.bin" > "$WORK/aia.pem" 2>/dev/null; then
      openssl x509 -in "$WORK/aia.pem" -out "$2" 2>/dev/null && return 0
    fi
  fi
  return 1
}

# Walk every chain upwards until each certificate ends at a self-signed root.
complete_chain() {
  local pass f new missing
  for pass in 1 2 3 4 5; do
    new=0
    for f in "$WORK"/set/*.pem "$WORK"/leaf/*.pem; do
      [[ -e "$f" ]] || continue
      is_self "$f" && continue
      # issuer already present?
      missing=1
      for c in "$WORK"/set/*.pem; do
        [[ -e "$c" ]] || continue
        if [[ "$(x509 -subject_hash -in "$c")" == "$(x509 -issuer_hash -in "$f")" ]]; then missing=0; break; fi
      done
      (( missing )) || continue
      if find_issuer "$f" "$WORK/issuer.pem" && ! in_set "$WORK/issuer.pem"; then
        info "+ issuer of '$(cn_of "$f")' -> '$(cn_of "$WORK/issuer.pem")'"
        cp "$WORK/issuer.pem" "$WORK/set/$(fp_of "$WORK/issuer.pem").pem"
        new=1
      fi
    done
    (( new )) || return 0
    : "$pass"
  done
}

fetch_chain() { # $1 = host[:port]
  local host="${1%%:*}" port=443 out args
  [[ "$1" == *:* ]] && port="${1##*:}"
  [[ "$host" =~ ^[A-Za-z0-9.-]+$ && "$port" =~ ^[0-9]+$ ]] || die "Invalid host '$1'."
  out="$WORK/chain-$host.txt"
  args=(s_client -showcerts -servername "$host" -connect "$host:$port")
  [[ -n "$PROXY" ]] && args+=(-proxy "$PROXY")
  "${TIMEOUT_CMD[@]}" openssl "${args[@]}" </dev/null >"$out" 2>"$WORK/err-$host.txt" || true
  grep -q -- '-----BEGIN CERTIFICATE-----' "$out" || {
    sed 's/^/        /' "$WORK/err-$host.txt" | tail -n 5 >&2
    die "No TLS certificate received from $host:$port (DNS, firewall or proxy? try -p host:port)."
  }
  rm -f "$WORK"/part-*.pem
  split_pem "$out" "$WORK/part-"
  local leaf="$WORK/part-01.pem"
  info "Server certificate : $(cn_of "$leaf")  (expires $(x509 -enddate -in "$leaf" | cut -d= -f2))"
  info "Chain sent         : $(find "$WORK" -maxdepth 1 -name 'part-*.pem' | wc -l | tr -d ' ') certificate(s)"
  if openssl x509 -noout -checkhost "$host" -in "$leaf" 2>/dev/null | grep -q 'does match'; then
    ok "Certificate name matches $host (SNI ok)"
  else
    warn "Certificate does NOT match '$host' - expect ORA-24263 unless you pass https_host."
  fi
  openssl x509 -checkend $((14 * 86400)) -noout -in "$leaf" >/dev/null 2>&1 \
    || warn "Server certificate of $host expires within 14 days."
  local part
  for part in "$WORK"/part-*.pem; do add_cert "$part" "$host"; done
  cp "$leaf" "$WORK/verify-$host.pem"
}

# ---------------------------------------------------------------- collect ----
step "Collecting certificates"
for h in "${HOSTS[@]+"${HOSTS[@]}"}"; do
  info "Fetching live chain from $h ..."
  fetch_chain "$h"
done
for f in "${CERT_FILES[@]+"${CERT_FILES[@]}"}"; do
  [[ -r "$f" ]] || die "Cannot read certificate file '$f'."
  info "Reading $f"
  add_bundle "$f" "$f"
done
for u in "${CERT_URLS[@]+"${CERT_URLS[@]}"}"; do
  info "Downloading $u"
  curl -fsSL --retry 3 --max-time "$CONNECT_TIMEOUT" "$u" -o "$WORK/download.bin" \
    || die "Download failed: $u"
  add_bundle "$WORK/download.bin" "$u"
done

step "Completing the chain up to the root CA"
complete_chain

# ---------------------------------------------------------------- verify -----
step "Verifying the chain offline (exactly what the wallet will contain)"
: > "$WORK/roots.pem"; : > "$WORK/inter.pem"
roots=0
for c in "$WORK"/set/*.pem; do
  [[ -e "$c" ]] || continue
  if is_self "$c"; then cat "$c" >> "$WORK/roots.pem"; roots=$((roots + 1))
  else cat "$c" >> "$WORK/inter.pem"; fi
done
(( roots > 0 )) || warn "No root CA found. Import it with -f root.crt or expect ORA-29024."

for v in "$WORK"/verify-*.pem; do
  [[ -e "$v" ]] || continue
  h="${v##*/verify-}"; h="${h%.pem}"
  vargs=("${NO_STORE[@]}" -CAfile "$WORK/roots.pem")
  [[ -s "$WORK/inter.pem" ]] && vargs+=(-untrusted "$WORK/inter.pem")
  if (( roots > 0 )) && openssl verify "${vargs[@]}" "$v" >/dev/null 2>&1; then
    ok "$h: chain verifies against the wallet content"
  else
    warn "$h: chain does NOT verify against the wallet content - ORA-29024 likely."
  fi
done

printf '\n  %-13s %-44s %s\n' "ROLE" "SUBJECT (CN)" "EXPIRES"
n=0
for want in root intermediate leaf; do          # roots first, then the rest
  for c in "$WORK"/set/*.pem; do
    [[ -e "$c" ]] || continue
    if is_self "$c"; then role="root"; elif is_ca "$c"; then role="intermediate"; else role="leaf"; fi
    [[ "$role" == "$want" ]] || continue
    cn="$(cn_of "$c")"; cn="${cn:-unknown}"
    printf '  %-13s %-44.44s %s\n' "$role" "$cn" "$(x509 -enddate -in "$c" | cut -d= -f2)"
    n=$((n + 1))
    safe="$(printf '%s' "$cn" | tr -c 'A-Za-z0-9._-' '_' | cut -c1-40)"
    cp "$c" "$WORK/import/$(printf '%02d' "$n")-${role}-${safe}.pem"
  done
done
for c in "$WORK"/leaf/*.pem; do
  [[ -e "$c" ]] || continue
  printf '  %-13s %-44.44s %s\n' "leaf (skip)" "$(cn_of "$c")" "$(x509 -enddate -in "$c" | cut -d= -f2)"
done
(( n > 0 )) || die "Nothing to import."

if (( CHECK_ONLY )); then
  step "Check mode: nothing was changed."
  exit 0
fi

# ---------------------------------------------------------------- password ---
state="$(docker exec "$CONTAINER" sh -c \
  "if [ -f '$WALLET_PATH/ewallet.p12' ]; then echo p12; elif [ -f '$WALLET_PATH/cwallet.sso' ]; then echo sso; else echo none; fi")"
if (( RECREATE )); then state="none"; fi
if [[ "$state" == "sso" && $SSO_ONLY -eq 0 ]]; then
  info "Existing wallet is password-less (cwallet.sso only) - switching to --sso-only."
  SSO_ONLY=1
fi
[[ "$state" == "p12" && $SSO_ONLY -eq 1 ]] \
  && die "Existing wallet has a password (ewallet.p12). Drop --sso-only or use --recreate."

if (( ! SSO_ONLY )); then
  if [[ -n "${WALLET_PASSWORD:-}" ]]; then
    :
  elif [[ -n "${WALLET_PASSWORD_FILE:-}" ]]; then
    [[ -r "$WALLET_PASSWORD_FILE" ]] || die "Cannot read WALLET_PASSWORD_FILE."
    WALLET_PASSWORD="$(head -n 1 "$WALLET_PASSWORD_FILE" | tr -d '\r')"
  elif [[ -t 0 ]]; then
    read -r -s -p "  Wallet password: " WALLET_PASSWORD; echo
    if [[ "$state" == "none" ]]; then
      read -r -s -p "  Repeat password: " pw2; echo
      [[ "$WALLET_PASSWORD" == "$pw2" ]] || die "Passwords do not match."
      unset pw2
    fi
  else
    die "No wallet password: set WALLET_PASSWORD or WALLET_PASSWORD_FILE, or use --sso-only."
  fi
  # orapki policy: 19c wants 8+ characters, 23ai/26ai orapki wants 16+, with
  # letters plus digits or special characters. We enforce the stricter rule.
  if [[ "$state" == "none" ]]; then
    [[ ${#WALLET_PASSWORD} -ge 16 && "$WALLET_PASSWORD" =~ [A-Za-z] && "$WALLET_PASSWORD" =~ [0-9[:punct:]] ]] \
      || die "Password too weak for orapki (min. 16 chars, letters + digits/special chars)."
  fi
  export WALLET_PASSWORD     # passed by NAME to docker exec, never on a command line
fi

# ---------------------------------------------------------------- import -----
step "Importing into ${CONTAINER}:${WALLET_PATH}"
# Staging folder on the volume, so a tool container sees it as well.
STAGE="${WALLET_BASE}/.import-$$"
docker exec -u 0 "$CONTAINER" mkdir -p "$STAGE"
docker cp "$WORK/import/." "$CONTAINER:$STAGE/" >/dev/null

ENV_ARGS=(-e WALLET_PASSWORD -e WALLET_PATH="$WALLET_PATH" -e WALLET_BASE="$WALLET_BASE"
          -e OWNER="$OWNER_UID:$OWNER_GID" -e STAGE="$STAGE"
          -e SSO_ONLY="$SSO_ONLY" -e RECREATE="$RECREATE" -e USE_JAR="$USE_JAR")
if [[ -n "$TOOL_IMAGE" ]]; then
  RUNNER=(docker run --rm -i -u 0 --network none --volumes-from "$CONTAINER"
          "${ENV_ARGS[@]}" --entrypoint bash "$TOOL_IMAGE" -s)
else
  RUNNER=(docker exec -i -u 0 "${ENV_ARGS[@]}" "$CONTAINER" bash -s)
fi

rc=0
"${RUNNER[@]}" <<'INNER' || rc=$?
set -euo pipefail
log() { printf '  [container] %s\n' "$*"; }
die() { printf '  [container] ERROR: %s\n' "$*" >&2; exit 1; }
trap 'rm -rf "$STAGE"' EXIT

# --- 1. find orapki (docker exec does not read .bashrc) ----------------------
if [[ "$USE_JAR" == "1" ]]; then
  ORAPKI=(java -cp "$STAGE/jars/*" oracle.security.pki.textui.OraclePKITextUI)
  log "orapki: oraclepki.jar on $(java -version 2>&1 | head -n 1)"
else
  BIN="$(command -v orapki 2>/dev/null || true)"
  [[ -z "$BIN" && -x "${ORACLE_HOME:-/x}/bin/orapki" ]] && BIN="$ORACLE_HOME/bin/orapki"
  [[ -n "$BIN" ]] || BIN="$(find /opt /u01 -type f -name orapki -perm -u+x 2>/dev/null | head -n 1 || true)"
  [[ -n "$BIN" ]] || die "orapki not found (is this an Oracle Database image?)."
  export ORACLE_HOME="${ORACLE_HOME:-$(dirname "$(dirname "$BIN")")}"
  ORAPKI=("$BIN")
  log "orapki: $BIN"
fi

# --- 2. orapki wrapper: exit code AND output, orapki is not always honest -----
if [[ "$SSO_ONLY" == "1" ]]; then
  PW_ADD=(-auto_login_only); PW_SHOW=()
else
  PW_ADD=(-pwd "$WALLET_PASSWORD"); PW_SHOW=(-pwd "$WALLET_PASSWORD")
fi
ORA_OUT=""
orapki_run() {
  local rc=0
  ORA_OUT="$("${ORAPKI[@]}" "$@" 2>&1)" || rc=$?
  [[ $rc -eq 0 ]] && ! grep -Eq 'PKI-[0-9]{5}' <<<"$ORA_OUT"
}

# --- 3. create or reuse the wallet -------------------------------------------
if [[ "$RECREATE" == "1" && -d "$WALLET_PATH" ]]; then
  BACKUP="${WALLET_PATH}.bak-$(date +%Y%m%d-%H%M%S)"
  mv "$WALLET_PATH" "$BACKUP"
  log "old wallet moved to $BACKUP"
fi
mkdir -p "$WALLET_PATH"
if [[ -f "$WALLET_PATH/cwallet.sso" || -f "$WALLET_PATH/ewallet.p12" ]]; then
  log "existing wallet reused"   # password is checked by the first 'add' below
else
  if [[ "$SSO_ONLY" == "1" ]]; then
    orapki_run wallet create -wallet "$WALLET_PATH" -auto_login_only \
      || { printf '%s\n' "$ORA_OUT" >&2; die "orapki wallet create failed."; }
  else
    orapki_run wallet create -wallet "$WALLET_PATH" -pwd "$WALLET_PASSWORD" -auto_login \
      || { printf '%s\n' "$ORA_OUT" >&2; die "orapki wallet create failed."; }
  fi
  log "new auto-login wallet created"
fi

# --- 4. import root + intermediates (PKI-04003 = already there = fine) --------
added=0; present=0
for cert in "$STAGE"/*.pem; do
  [[ -e "$cert" ]] || continue
  name="$(basename "$cert" .pem)"
  if orapki_run wallet add -wallet "$WALLET_PATH" -trusted_cert -cert "$cert" "${PW_ADD[@]}"; then
    log "added    $name"; added=$((added + 1))
  elif grep -q 'PKI-04003' <<<"$ORA_OUT"; then
    log "present  $name"; present=$((present + 1))
  elif grep -q 'PKI-02003' <<<"$ORA_OUT"; then
    die "Cannot open the wallet: wrong wallet password (PKI-02003)."
  else
    printf '%s\n' "$ORA_OUT" >&2
    die "Import of $name failed."
  fi
done
log "$added added, $present already present"

# --- 5. least privilege: oracle may read, nobody else may touch --------------
chown -R "$OWNER" "$WALLET_PATH"
chmod 750 "$WALLET_PATH"
find "$WALLET_PATH" -maxdepth 1 -type f -exec chmod 600 {} +
if [[ "$(stat -c '%u' "$WALLET_BASE")" == "0" ]]; then   # we created it as root
  chown "$OWNER" "$WALLET_BASE"
  chmod 750 "$WALLET_BASE"
fi
[[ "$(stat -c '%u:%g %a' "$WALLET_PATH/cwallet.sso")" == "$OWNER 600" ]] \
  || die "Unexpected owner/mode on cwallet.sso."
# shellcheck disable=SC2012
ls -l "$WALLET_PATH" | sed 's/^/  [container] /'

# --- 6. show what the database will trust ------------------------------------
orapki_run wallet display -wallet "$WALLET_PATH" "${PW_SHOW[@]}" \
  || { printf '%s\n' "$ORA_OUT" >&2; die "orapki wallet display failed."; }
printf '%s\n' "$ORA_OUT" | awk '/Trusted Certificates:/{t=1} t && NF' | sed 's/^/  [container] /'
INNER
unset WALLET_PASSWORD
docker exec -u 0 "$CONTAINER" rm -rf "$STAGE" 2>/dev/null || true
(( rc == 0 )) || die "Wallet setup inside the container failed (exit code $rc)."
step "Done"
cat <<EOF
  Wallet path for PL/SQL and APEX:

      file:${WALLET_PATH}

  UTL_HTTP.SET_WALLET('file:${WALLET_PATH}', NULL);   -- auto-login, no password
  Next: network ACL for host + port 443 (see wallet_acl_test.sql).
EOF

And this is what a real run against the full gvenzl/oracle-free:23 container looks like — note the fallback line, because that image has no Java:

Terminal output of the first run of setup_oracle_wallet.sh against api.example.com: host checks pass, orapki falls back to a throwaway eclipse-temurin JRE container, the script completes the chain via AIA with the APEX Note Test Issuing CA and Root CA, verifies it offline, creates a new auto-login wallet, adds 2 certificates and sets owner 54321 with mode 600.
Real runFigure 3 — First run on the Docker host: chain completed via AIA, verified offline, new auto-login wallet with root + intermediate, files owned by oracle (UID 54321) with mode 600.

Run it a second time and the import section says 0 added, 2 already present — nothing else changes:

Terminal output of the second run of setup_oracle_wallet.sh with the same arguments: the existing wallet is reused, both CA certificates are reported as present, 0 added, 2 already present.
Real runFigure 4 — Second run with the same arguments: existing wallet reused, 0 added, 2 already present — safe for cron.

All options at a glance:

OptionDefaultPurpose
-H host[:port]—Fetch the live chain (repeatable)
-f file / -u url—Import a PEM, DER or PKCS#7 file — a server certificate is enough, its issuers are found automatically
-c nameoracle-dbDocker container (ORA_CONTAINER)
-w namehttps_walletWallet folder name
-b path/opt/oracle/oradata/walletBase path inside the container — keep it on the volume
-o user:grouporacle:oinstallOwner, resolved to numeric ids of the DB container
-p host:port—HTTP proxy for fetching the chain
-T imageauto: eclipse-temurin:21-jreHelper image when the DB image cannot run orapki
-s / -l / -r / -noffSSO-only wallet / include server cert / back up and rebuild / check only
04
Step four

Open the Network ACL

SQL · as SYS or ADMIN · parsing schema + APEX schema

The wallet decides whom the database trusts; the ACL decides who may connect at all. You need both, and the ACL has a trap: there are two different principals. Your own PL/SQL runs as the application's parsing schema. APEX_WEB_SERVICE and REST Data Sources, however, open the connection as the APEX engine schema — APEX_260200 for APEX 26.2. Grant only one of them and exactly half of your code works.

All SQL lives in one file, wallet_acl_test.sql. Adjust the define block at the top, then run section 1 as a DBA:

SQLwallet_acl_test.sqlheader + section 1
-- >>> adjust these values <<<
define target_host    = 'api.example.com'
define target_url     = 'https://api.example.com/'
define wallet_path    = 'file:/opt/oracle/oradata/wallet/https_wallet'
define parsing_schema = 'MY_APP_SCHEMA'
define apex_workspace = 'MY_WORKSPACE'

set serveroutput on size unlimited
set define on verify off feedback on

-- =============================================================================
-- 1. NETWORK ACL  (as SYS / ADMIN)
--    Without it every call ends in ORA-29273 + ORA-24247, wallet or not.
-- =============================================================================
DECLARE
    l_apex_schema VARCHAR2(128);
    -- every host the database must reach (port 443)
    l_hosts       SYS.ODCIVARCHAR2LIST := SYS.ODCIVARCHAR2LIST(
                      '&target_host',
                      'webdav.hidrive.strato.com');
BEGIN
    BEGIN
        SELECT schema
          INTO l_apex_schema
          FROM dba_registry
         WHERE comp_id = 'APEX';
    EXCEPTION
        WHEN NO_DATA_FOUND THEN
            DBMS_OUTPUT.PUT_LINE('APEX is not installed - only the parsing schema gets an ACE.');
    END;

    FOR i IN 1 .. l_hosts.COUNT LOOP
        -- your own PL/SQL (UTL_HTTP) runs as the parsing schema
        DBMS_NETWORK_ACL_ADMIN.APPEND_HOST_ACE(
            host       => l_hosts(i),
            lower_port => 443,
            upper_port => 443,
            ace        => XS$ACE_TYPE(
                              privilege_list => XS$NAME_LIST('http'),
                              principal_name => UPPER('&parsing_schema'),
                              principal_type => XS_ACL.PTYPE_DB));

        -- APEX_WEB_SERVICE and REST Data Sources connect as the APEX engine schema
        IF l_apex_schema IS NOT NULL THEN
            DBMS_NETWORK_ACL_ADMIN.APPEND_HOST_ACE(
                host       => l_hosts(i),
                lower_port => 443,
                upper_port => 443,
                ace        => XS$ACE_TYPE(
                                  privilege_list => XS$NAME_LIST('connect'),
                                  principal_name => l_apex_schema,
                                  principal_type => XS_ACL.PTYPE_DB));
        END IF;
        DBMS_OUTPUT.PUT_LINE('ACE ok: ' || l_hosts(i) || ':443 -> '
                             || UPPER('&parsing_schema')
                             || NVL2(l_apex_schema, ', ' || l_apex_schema, NULL));
    END LOOP;
END;
/

-- check: who may connect where?
SELECT host, lower_port, upper_port, principal, privilege
  FROM dba_host_aces
 WHERE host IN ('&target_host', 'webdav.hidrive.strato.com')
 ORDER BY host, principal, privilege;

I use the http privilege for the parsing schema because it is the narrowest one that UTL_HTTP needs, and connect for the APEX schema because that is what the APEX installation guide grants. The port range is deliberately 443 to 443 — an ACE without ports opens every port on that host.

05
Step five

Optional: Keep Endpoints and Wallet Path in a Table

SQL · parsing schema · no passwords allowed

I do not like URLs and file paths hardcoded in packages. When the wallet moves or an endpoint changes, I want to change a row, not deploy code. This parameter table is the generic version of the one I use in my own projects, with one opinionated addition: a check constraint that rejects any parameter whose name looks like a password. With an auto-login wallet there is no wallet password to store, so the table never needs one — and the constraint makes sure nobody adds one "just for now".

SQLwallet_acl_test.sqlsection 2
-- =============================================================================
-- 2. OPTIONAL: CONFIG TABLE  (as parsing schema)
--    Endpoints and wallet paths belong in data, not in code.
--    Secrets do not belong here at all - the check constraint enforces it.
-- =============================================================================
CREATE TABLE global_parameter (
    glpa_id         NUMBER GENERATED BY DEFAULT ON NULL AS IDENTITY
                    CONSTRAINT global_parameter_pk PRIMARY KEY,
    glpa_type       VARCHAR2(30)   DEFAULT 'SYSTEM' NOT NULL,
    glpa_name       VARCHAR2(128)  NOT NULL
                    CONSTRAINT global_parameter_uk UNIQUE,
    glpa_value      VARCHAR2(4000),
    glpa_active_yn  VARCHAR2(1)    DEFAULT 'Y' NOT NULL
                    CONSTRAINT global_parameter_active_ck CHECK (glpa_active_yn IN ('Y', 'N')),
    glpa_remark     VARCHAR2(4000),
    glpa_created    TIMESTAMP      DEFAULT SYSTIMESTAMP NOT NULL,
    glpa_created_by VARCHAR2(255)  DEFAULT COALESCE(SYS_CONTEXT('APEX$SESSION', 'APP_USER'),
                                                    SYS_CONTEXT('USERENV', 'SESSION_USER')) NOT NULL,
    CONSTRAINT global_parameter_no_secret_ck
        CHECK (NOT REGEXP_LIKE(glpa_name, 'PASSWORD|PASSWD|PWD|SECRET', 'i'))
);

CREATE OR REPLACE FUNCTION get_param (p_name IN VARCHAR2)
    RETURN VARCHAR2
    RESULT_CACHE
IS
    l_value global_parameter.glpa_value%TYPE;
BEGIN
    SELECT glpa_value
      INTO l_value
      FROM global_parameter
     WHERE glpa_name = UPPER(p_name)
       AND glpa_active_yn = 'Y';
    RETURN l_value;
EXCEPTION
    WHEN NO_DATA_FOUND THEN
        RAISE_APPLICATION_ERROR(-20001, 'Missing or inactive parameter ' || p_name);
END get_param;
/

INSERT INTO global_parameter (glpa_name, glpa_value, glpa_remark)
VALUES ('HTTPS_WALLET_PATH', '&wallet_path',
        'auto-login wallet built by setup_oracle_wallet.sh - no password needed');
INSERT INTO global_parameter (glpa_name, glpa_value, glpa_remark)
VALUES ('API_BASE_URL', '&target_url', 'REST API used by the application');
INSERT INTO global_parameter (glpa_name, glpa_value, glpa_remark)
VALUES ('WEBDAV_ENDPOINT', 'https://webdav.hidrive.strato.com/', 'Example: HiDrive WebDAV');
COMMIT;

RESULT_CACHE makes get_param practically free on repeated calls, and Oracle invalidates the cache automatically when the table changes. If you already have a parameter table, keep yours — the only two values the tests below need are the wallet path and a URL.

06
Step six

Test With UTL_HTTP

SQL · parsing schema · quick test + full test

Two tests, for two different questions. The quick one answers "does TLS work at all?" with a single UTL_HTTP.REQUEST. Note the second argument of SET_WALLET: NULL. With an auto-login wallet that is not a shortcut — it is the correct value, and as you will see in the troubleshooting section, a wrong password here is worse than none.

The full test behaves like a real integration: BEGIN_REQUEST, a header, the status code and the response headers, and a clean END_RESPONSE even when something fails — leaked responses are how you eventually hit ORA-29270: too many open HTTP requests. It sends an OPTIONS request to the WebDAV endpoint, which is a cheap way to talk to a server without downloading anything. Both blocks and their output, one tab each:

PL/SQLwallet_acl_test.sqlsection 3 · UTL_HTTP.REQUEST
-- =============================================================================
-- 3. QUICK TEST: UTL_HTTP.REQUEST  (as parsing schema)
-- =============================================================================
DECLARE
    l_body VARCHAR2(32767);
BEGIN
    UTL_HTTP.SET_DETAILED_EXCP_SUPPORT(TRUE);   -- raise ORA-29024 etc. directly
    UTL_HTTP.SET_WALLET(get_param('HTTPS_WALLET_PATH'), NULL);   -- auto-login: NULL

    l_body := UTL_HTTP.REQUEST(url => get_param('API_BASE_URL'));

    DBMS_OUTPUT.PUT_LINE('TLS OK - first bytes: ' || SUBSTR(l_body, 1, 200));
EXCEPTION
    WHEN OTHERS THEN
        DBMS_OUTPUT.PUT_LINE('SQLERRM : ' || SQLERRM);
        DBMS_OUTPUT.PUT_LINE('DETAIL  : ' || UTL_HTTP.GET_DETAILED_SQLERRM);
END;
/
PL/SQLwallet_acl_test.sqlsection 4 · BEGIN_REQUEST
-- =============================================================================
-- 4. FULL TEST: status code + headers, like a real integration
--    A 401 or 404 is a SUCCESS here - TLS, ACL and wallet all worked.
-- =============================================================================
DECLARE
    l_req  UTL_HTTP.REQ;
    l_resp UTL_HTTP.RESP;
    l_name VARCHAR2(256);
    l_val  VARCHAR2(1024);
BEGIN
    UTL_HTTP.SET_DETAILED_EXCP_SUPPORT(TRUE);
    UTL_HTTP.SET_WALLET(get_param('HTTPS_WALLET_PATH'), NULL);
    UTL_HTTP.SET_TRANSFER_TIMEOUT(20);

    l_req := UTL_HTTP.BEGIN_REQUEST(
                 url    => get_param('WEBDAV_ENDPOINT'),
                 method => 'OPTIONS');
    UTL_HTTP.SET_HEADER(l_req, 'User-Agent', 'apexnote-wallet-test/1.0');

    l_resp := UTL_HTTP.GET_RESPONSE(l_req);
    DBMS_OUTPUT.PUT_LINE('HTTP ' || l_resp.status_code || ' ' || l_resp.reason_phrase);

    FOR i IN 1 .. LEAST(UTL_HTTP.GET_HEADER_COUNT(l_resp), 8) LOOP
        UTL_HTTP.GET_HEADER(l_resp, i, l_name, l_val);
        DBMS_OUTPUT.PUT_LINE('  ' || l_name || ': ' || SUBSTR(l_val, 1, 80));
    END LOOP;
    UTL_HTTP.END_RESPONSE(l_resp);
EXCEPTION
    WHEN OTHERS THEN
        DBMS_OUTPUT.PUT_LINE('SQLERRM : ' || SQLERRM);
        DBMS_OUTPUT.PUT_LINE('DETAIL  : ' || UTL_HTTP.GET_DETAILED_SQLERRM);
        BEGIN
            UTL_HTTP.END_RESPONSE(l_resp);
        EXCEPTION
            WHEN OTHERS THEN NULL;   -- no open response
        END;
END;
/
Outputsections 3 + 4 on my test containerreal run
-- section 3: quick test against my local test API (api.example.com)
TLS OK - first bytes: {"status": "ok", "service": "api.example.com (local test API)", "tls": "TLSv1.3"}

PL/SQL procedure successfully completed.

-- section 4: WebDAV endpoint, through my test machine's egress proxy
HTTP 403 Forbidden
x-deny-reason: host_not_allowed
content-type: text/plain
connection: close

PL/SQL procedure successfully completed.
Terminal window: the same UTL_HTTP.REQUEST call in SQL*Plus, now with the wallet path file:/opt/oracle/oradata/wallet/https_wallet and a NULL password, returns the JSON response of the test API; section 3 of wallet_acl_test.sql prints TLS OK.
Real runFigure 5 — After the script: the same call with 'file:/opt/oracle/oradata/wallet/https_wallet' and NULL returns the API response, and section 3 prints TLS OK.
A 401 or 403 is a success here. Any HTTP status code means the TLS handshake worked: ACL, wallet, chain and host name all passed. Authentication is the next problem, not this one. My test container sits behind a TLS-inspecting egress proxy that answered 403 for the WebDAV host — and the first run of section 4 actually failed with ORA-29024, because the wallet did not yet contain the proxy's CA. One more -H webdav.hidrive.strato.com run of the script (2 added) and it passed. Against the real HiDrive endpoint you will typically see 401 Unauthorized until you send credentials.
07
Step seven

APEX_WEB_SERVICE — and the Instance-Wide Wallet

SQL · APEX 26.2 · per call or once per instance

Inside APEX you will rarely call UTL_HTTP directly. APEX_WEB_SERVICE.MAKE_REST_REQUEST takes the same wallet through p_wallet_path and, again, NULL as the password. Outside an APEX session — SQLcl, SQL Developer, a scheduler job — set the workspace first:

PL/SQLwallet_acl_test.sqlsection 5 · APEX_WEB_SERVICE
-- =============================================================================
-- 5. APEX_WEB_SERVICE  (as parsing schema, APEX installed)
-- =============================================================================
DECLARE
    l_clob CLOB;
BEGIN
    -- only needed outside an APEX session (SQLcl, SQL Developer, scheduler job)
    APEX_UTIL.SET_WORKSPACE(p_workspace => '&apex_workspace');

    l_clob := APEX_WEB_SERVICE.MAKE_REST_REQUEST(
                  p_url         => get_param('API_BASE_URL'),
                  p_http_method => 'GET',
                  p_wallet_path => get_param('HTTPS_WALLET_PATH'),
                  p_wallet_pwd  => NULL);   -- auto-login wallet

    DBMS_OUTPUT.PUT_LINE('HTTP ' || APEX_WEB_SERVICE.G_STATUS_CODE);
    DBMS_OUTPUT.PUT_LINE(DBMS_LOB.SUBSTR(l_clob, 200, 1));
END;
/
Oracle APEX 26.2 SQL Workshop, SQL Commands as schema APP_DEV: an anonymous block calls APEX_WEB_SERVICE.MAKE_REST_REQUEST with p_wallet_path from get_param and p_wallet_pwd NULL; the result pane shows HTTP 200 and the JSON body of api.example.com.
APEX 26.2Figure 6 — Section 5 in SQL Workshop → SQL Commands (APEX 26.2, ORDS 26.3): APEX_WEB_SERVICE with the wallet path and a NULL password — HTTP 200.
OutputAPEX 26.2 · real runsoutput
-- section 5 on APEX 26.2
HTTP 200
{"status": "ok", "service": "api.example.com (local test API)", "tls": "TLSv1.3"}

-- after section 6: the same call WITHOUT p_wallet_path
HTTP 200
{"status": "ok", "service": "api.example.com (local test API)", "tls": "TLSv1.3"}

-- section 5 without the APEX_260200 ACE (only the parsing schema granted)
ORA-29273: HTTP request failed
ORA-06512: at "APEX_260200.WWV_FLOW_WEB_SERVICES", line 950
...
ORA-24247: network access denied by access control list (ACL)

The last part of that output is the proof for step 04: with an ACE for the parsing schema only, UTL_HTTP worked and APEX_WEB_SERVICE failed — the stack shows the call leaving through APEX_260200.

Passing the wallet path to every call works, but there is a cleaner option for an instance you control: set it once. In Administration Services → Manage Instance → Instance Settings → Wallet, enter file:/opt/oracle/oradata/wallet/https_wallet and leave the password empty — or run section 6 as a DBA. From then on APEX_WEB_SERVICE, REST Data Sources, REST-enabled SQL and Social Sign-In all use that wallet without a p_wallet_path anywhere in your code.

Oracle APEX 26.2 Administration Services, Manage Instance, Instance Settings, Wallet tab: Wallet Path file:/opt/oracle/oradata/wallet/https_wallet, Auto-login Wallet checked, password fields empty.
APEX 26.2Figure 7 — Administration Services → Manage Instance → Instance Settings → Wallet: the path, Auto-login Wallet checked, no password.
PL/SQLwallet_acl_test.sqlsection 6 · instance wallet
-- =============================================================================
-- 6. OPTIONAL: INSTANCE-WIDE WALLET  (as SYS / APEX instance admin)
--    Same as Administration Services > Manage Instance > Instance Settings >
--    Wallet. Afterwards p_wallet_path can be omitted everywhere in APEX.
-- =============================================================================
BEGIN
    APEX_INSTANCE_ADMIN.SET_PARAMETER('WALLET_PATH', '&wallet_path');
    COMMIT;
END;
/
Oracle APEX 26.2 SQL Commands: APEX_WEB_SERVICE.MAKE_REST_REQUEST without any p_wallet_path returns HTTP 200 and the JSON response after the instance wallet was set.
APEX 26.2Figure 8 — With the instance wallet set, the same call without any p_wallet_path — still HTTP 200.

Which one should you use? Instance wallet when you own the instance and all workspaces may call the same CAs — that is my default for single-purpose containers. Per-call wallet when one instance hosts several customers, or when a particular integration must trust a private CA that nobody else should trust. The script supports both: give each integration its own -w name.


How It Works Inside

What an Oracle wallet actually is

A wallet is a folder with two files that matter. ewallet.p12 is a standard PKCS#12 container, encrypted with the wallet password — this is what orapki edits. cwallet.sso is the auto-login copy: the same content, readable without a password, which is what the database opens when you pass NULL. orapki … -auto_login keeps both in sync on every change. The .lck files are lock files and can be ignored. For HTTPS calls the wallet only holds trusted certificates — public CA certificates, nothing secret — until the day you add a client certificate for mutual TLS.

Why the full chain — and why not the server certificate

Certificate chain: root and intermediate go into the wallet, the server certificate does not The server certificate for api.example.com is valid for 90 days and is signed by an intermediate CA, which is signed by a self-signed root CA. The script imports the root and the intermediate into the wallet and skips the server certificate, so renewals do not break the wallet. Root CA · e.g. ISRG Root X1 self-signed · valid for decades never sent by the server — only the wallet has it IN THE WALLET signed by Intermediate CA · e.g. R11 signed by the root · valid a few years sent by most servers — but not by all IN THE WALLET signed by Server (leaf) · api.example.com signed by the intermediate · 90 days today 47 days maximum from 2029 (CA/B Forum) NOT IMPORTED
Figure 9 — Trust the CAs, not the certificate. The leaf changes every few weeks; root and intermediate stay for years.

TLS trust is a chain. The server presents its own certificate (the leaf), signed by an intermediate CA, signed by a root CA. Oracle accepts the connection only if it can build that chain up to a certificate in the wallet. Servers usually send the leaf and the intermediate, never the root — so the root must come from the wallet. And when a server also forgets the intermediate, Oracle will not fetch it; my root-only test wallet failed with exactly that ORA-29024. Importing root plus intermediates covers both cases.

What the script deliberately does not import is the leaf. Many guides tell you to export the certificate from the browser and add that. It works — for at most 90 days with Let's Encrypt, and the CA/Browser Forum has already voted to cut the maximum lifetime of all public certificates to 47 days by 2029. A wallet that trusts the leaf breaks on the renewal date; a wallet that trusts the CAs does not even notice it.

Why the chain is fetched on the host

My first version ran openssl inside the container. Then I tested the slim image: no openssl, and on both gvenzl images no Java for orapki either. The host, on the other hand, always has openssl and a network path to the API. So the split is: the host collects, completes and verifies; the container only runs orapki. Certificates travel in through a staging folder on the volume, the password travels as an environment variable passed by name (docker exec -e WALLET_PASSWORD without a value), so it never appears in a process list on the host.

When orapki cannot run in the database container, the script starts a throwaway eclipse-temurin:21-jre container with --volumes-from and --network none. It sees the same volume, runs Oracle's oraclepki.jar — published by Oracle on Maven Central, SHA-1 verified and cached on the host — and disappears. Ownership is set by numeric UID and GID, resolved inside the database container, so the files belong to the right oracle user even though a different container wrote them.

Flow of setup_oracle_wallet.sh The script checks the host and the container, decides whether orapki can run in the container or a throwaway JRE container is needed, fetches the chain with openssl, completes missing issuers from the host trust store or the AIA URL, verifies the chain offline, stops in check mode, otherwise creates or reuses the wallet, imports root and intermediate certificates and sets owner and permissions. Run on the Docker hostsetup_oracle_wallet.sh -H api.example.com Checksdocker · container running · volume · owner missing warning: wallet lost orapki runs inside? no JRE container+ oraclepki.jar · --volumes-from yes → docker exec Fetch the live chainopenssl s_client -showcerts -servername ends at a root? no find the issuerhost trust store → AIA URL yes openssl verify -no-CApathagainst the wallet content only fails warning: ORA-29024 ahead check mode -n? yes print table · stop no Create or reuse walletorapki wallet create -auto_login Import root + intermediatesorapki wallet add · PKI-04003 = skip chown oracle:oinstall · 750 / 600
Figure 10 — What setup_oracle_wallet.sh does, in order. Everything above "Create or reuse wallet" runs on the host and changes nothing.

Auto-login, the password, and --sso-only

Because the wallet only contains public CA certificates, there is a good argument for having no password at all: --sso-only creates the wallet with orapki -auto_login_only, which writes only cwallet.sso. Nothing to store, nothing to rotate, nothing to leak. I still default to a password-protected ewallet.p12 plus auto-login, because the day someone adds a client certificate to the same wallet, the password matters. The script detects an existing SSO-only wallet and switches modes by itself.

Why 23ai and 26ai still need a wallet

Newer releases can read the operating system's certificate store, so I tested whether I could skip the wallet on 26ai. I added my test root CA to the container's OS trust store with update-ca-trust and restarted the database. Without SET_WALLET: ORA-29024. With the wallet path system:: ORA-29024. With the wallet: success. Even where the OS store does work, inside a container it is the image's CA bundle — frozen at build time, without your private CA, and reset with every re-created container. A wallet on the volume is the one thing that behaves the same on 19c, 21c, 23ai and 26ai.

Keeping it current

CAs rotate intermediates, and roots get replaced every decade or two. Because the script is idempotent, the maintenance plan is a cron entry: it adds whatever is new and leaves everything else alone.

Cron/etc/cron.d/oracle-wallet
# every Monday at 03:17 - adds new CA certificates, changes nothing otherwise
17 3 * * 1  root  WALLET_PASSWORD_FILE=/root/.oracle_wallet_pw /usr/local/sbin/setup_oracle_wallet.sh -c oracle-db -w https_wallet -H api.example.com -H webdav.hidrive.strato.com >> /var/log/oracle-wallet.log 2>&1

The cron job has no terminal, so it never prompts; it fails cleanly if the password file is missing. The 14-day expiry warning ends up in the log, and the database server process re-reads the wallet for new sessions, so no restart is needed.


Security & Edge Cases

Why not chmod 777

My own old version of this script ended with chmod -R 777. It "fixed" ORA-28759, and that is exactly why it is dangerous: it hides the real problem behind a much bigger one. A world-writable trust store means any process in the container can add its own CA — and from that moment it can impersonate every API your database talks to, read the payloads and change the responses. A world-readable cwallet.sso is, by design, a usable wallet for whoever copies it. I reproduced the original error: wallet files owned by root with mode 600 give ORA-28759. The fix is the owner, not the mode:

PathOwnerModeWhy
/opt/oracle/oradata/walletoracle:oinstall750Only the database user and its group may enter
…/https_wallet/oracle:oinstall750Nobody else can list or add files
cwallet.sso, ewallet.p12, *.lckoracle:oinstall600Only the server process reads them — the script fails if they end up different

More things I check before I call it done

  • No secrets in code or tables. Auto-login plus NULL means there is no wallet password in PL/SQL, in APEX or in a parameter table — and the check constraint keeps it that way. API credentials belong in APEX Web Credentials, not next to the wallet path.
  • Trust as little as possible. Every CA in a wallet is trusted for every host the ACL allows. A private or proxy CA gets its own wallet with -w, used only by the code that needs it.
  • Company proxies that inspect TLS. Import the chain the database will see. My test machine sends all outbound traffic through such a proxy — the script picked up the proxy's CA, which was exactly right for that container. Fetch from a different network and you import the wrong chain.
  • Do not pin the leaf. -l exists for self-signed internal servers. For anything with a public certificate it is a time bomb with a 90-day fuse.
  • Mutual TLS is a different story. A client certificate with a private key makes the wallet a secret, and the schema then needs an additional wallet ACE (DBMS_NETWORK_ACL_ADMIN.APPEND_WALLET_ACE with use-client-certificates). This script does not import private keys on purpose.
  • Input is validated. Host, wallet name, base path and owner are checked against strict patterns before they reach a path or a docker command; the inner script runs with set -euo pipefail, and a wallet is only replaced after a timestamped backup.

Troubleshooting: Every Error, Reproduced

Every message below is copied from my test container, not from the documentation. Start with the script's check mode (-n) and the detailed error of the test blocks — between them they point to the gate that failed.

ORA-29273: HTTP request failed

Not an error, an envelope. Read the stack: the real cause is the second ORA- line. Or call UTL_HTTP.SET_DETAILED_EXCP_SUPPORT(TRUE) and UTL_HTTP.GET_DETAILED_SQLERRM, as the test blocks do:

Outputreal error stackORA-29273 envelope
-- SET_DETAILED_EXCP_SUPPORT(FALSE)  (default)
ORA-29273: HTTP request failed
ORA-06512: at "SYS.UTL_HTTP", line 1594
ORA-29024: Certificate validation failure
Outputreal error stackreal cause first
-- SET_DETAILED_EXCP_SUPPORT(TRUE)
ORA-29024: Certificate validation failure
ORA-06512: at "SYS.UTL_HTTP", line 1594

ORA-29024: Certificate validation failure

Gate 3. The wallet does not contain a CA that completes the server's chain. Typical causes: no SET_WALLET call at all; a wallet with only the root while the server does not send its intermediate; the CA rotated to a new intermediate or root; or the request went through a TLS-inspecting proxy whose CA you never imported. Fix: run the script with -n -H yourhost, compare the chain with what is in the wallet, then run it without -n.

Oracle APEX 26.2 SQL Commands: APEX_WEB_SERVICE.MAKE_REST_REQUEST without a wallet fails with ORA-29273 and ORA-29024 Certificate validation failure, raised in APEX_260200.WWV_FLOW_WEB_SERVICES and SYS.UTL_HTTP.
APEX 26.2Figure 11 — What ORA-29024 looks like from APEX: no p_wallet_path, no instance wallet. Note the stack — the call runs through APEX_260200, which is why that schema needs its own ACE.

ORA-24247: network access denied by access control list (ACL)

Gate 1. Check dba_host_aces for three things: the exact host name (no scheme, no path), the port (an ACE for 443 does not cover 8443 — I hit this in testing with a non-standard port), and the principal. Remember the second principal: code in APEX_WEB_SERVICE connects as APEX_260200, not as your schema. Behind a proxy, the ACE must be for the proxy host and port.

ORA-28759: failure to open file

Gate 2. The database could not open the wallet — and on 26ai this one error covers four different causes, all reproduced:

CauseFix
Path typo or missing file: prefix'file:/opt/oracle/oradata/wallet/https_wallet' — the folder, not the file
Files owned by root (created with docker exec -u 0)chown -R oracle:oinstall — the script does it
Wallet without cwallet.sso and NULL passwordRecreate with -auto_login (script default)
Wrong password passed to an auto-login walletPass NULL. A stale password in a parameter table breaks a wallet that would have worked without one

ORA-24263: remote server address on certificate and target address mismatch

Gate 4 — the SNI and host-name case. You called the server by IP address, by an internal alias, or through a load balancer whose certificate carries a different name. Use the name that is in the certificate; the script's Certificate does NOT match warning tells you in advance. If you really must connect by another address, both APIs accept the expected name: https_host in UTL_HTTP.REQUEST / BEGIN_REQUEST and p_https_host in APEX_WEB_SERVICE. I tested it: the same call by IP failed with ORA-24263 and succeeded with https_host => 'localhost'.

ORA-29106: Cannot import PKCS #12 wallet

On older releases this is the classic error for a wrong wallet password or an ewallet.p12 the database cannot read. On 26ai I could not provoke it — every wrong password came back as ORA-28759 — so treat both as the same family. Fix: auto-login with NULL; if the wallet was written by a much newer orapki than your database, rebuild it with the orapki of the database home (-T with your full database image, or ORAPKI_JAR_VERSION set to your release).

Script errors you may see

MessageMeaning
ERROR: No Java (from orapki)Image without JDK — the script switches to the JRE container automatically
PKI-02003: Unable to load the wallet … incorrect passwordWrong password for an existing wallet. Note that orapki wallet display still succeeds on an auto-login wallet with a wrong password — only add really checks it
PKI-04003: The trusted certificate is already presentNot an error — reported as present
No TLS certificate receivedDNS, firewall or proxy between the host and the API — try -p proxyhost:port
Cannot download … (HTTP 429)Maven Central rate limit — run again; the jar is cached after the first success

Proxy in between

If the database must use an HTTP proxy, set it per session with UTL_HTTP.SET_PROXY('proxy.example.com:3128') or per call with p_proxy_override in APEX_WEB_SERVICE, open the ACL for the proxy host and port, and fetch the chain the same way with -p. If the proxy inspects TLS, the chain you need is the proxy's, not the API's.

📋 Copy the code
setup_oracle_wallet.sh + wallet_acl_test.sql

Complete code in this post — copy it from Step 3. The SQL file is split into its six sections in Steps 4–7; every code box has a Copy button. MIT licensed and free for personal and commercial projects. Originally developed by S&H Software Solutions – first published on APEX Note (October 2026).

Questions? Leave a comment below.


Final Thoughts

The orapki commands were never the hard part. What cost the time were the things around them: the server that forgets its intermediate, the image that ships orapki without the Java it needs, the wallet that worked until the container was re-created, and the password that broke a wallet which would have worked with none. Writing this post, I reproduced every one of those failures on purpose — and each one became a line in the script.

My rule for HTTPS from the database is now short: trust the CAs, not the certificate; keep the wallet on the volume; pass NULL; never 777. Everything else is automation.

If your image, your CA or your proxy does something the script does not handle yet, leave a comment below — I read all of them.

SH
Sajjad Hanifa
Software Developer · S&H Software Solutions · Oracle APEX, PL/SQL, Docker
Oracle APEX Oracle Wallet orapki UTL_HTTP APEX_WEB_SERVICE Docker ORA-29024 PL/SQL Security

 {fullWidth}

Please Select Embedded Mode To Show The Comment System.*

Previous Post Next Post

نموذج الاتصال