Serving a protected PDF from APEX through an Ajax callback
Guide Oracle APEX Tested on APEX 26.1.0securityajax callbackpdfapex_web_servicejavascript
A very common way to add “Download PDF” to an APEX report is to link straight to the report server:
https://reports.example.com/print?report=invoice&invoice_id=10452
It works, and it has a problem that is easy to miss. The browser now holds a URL with the record id in it. Change 10452 to 10453 and you get somebody else’s invoice, because the report server knows nothing about your APEX session or who is allowed to see what. This is an IDOR (insecure direct object reference), and hiding the link behind a button doesn’t fix it: the URL is still one click away in the browser’s developer tools.
The fix is to keep the report server out of the browser entirely. The browser asks APEX for the file; an Ajax callback checks who is asking, fetches the PDF from the report server on the server side, and streams it back. Here is the full pattern, as I built and tested it.
The flow#
- The download button calls a small JavaScript function with the invoice id.
- The function requests an application process (an Ajax callback) inside the user’s own APEX session.
- The process checks, in order: is someone logged in, does this invoice belong to them, is it ready.
- If all three pass, it fetches the PDF with
apex_web_serviceand streams it back. - If any check fails, it returns an HTTP status and a short, translated message instead. The JavaScript shows the message.
The report server’s address never reaches the browser, and the only id the browser sends is checked against the session before anything is fetched.
The application process#
Shared Components → Application Processes → Create, Process Point = Ajax Callback, name DOWNLOAD_INVOICE:
declare
l_invoice_id number;
l_status varchar2(20);
l_pdf blob;
-- one place for every refusal: an HTTP status + a plain-text message
procedure refuse (p_code in number, p_msg in varchar2) is
begin
owa_util.status_line(p_code, null, false);
owa_util.mime_header('text/plain', false, 'UTF-8');
owa_util.http_header_close;
htp.prn(p_msg);
apex_application.stop_apex_engine;
end;
begin
-- input: anything that is not a number becomes NULL, and NULL is refused below
l_invoice_id := to_number(apex_application.g_x01 default null on conversion error);
-- 1 + 2: logged in, and the invoice is theirs (same generic message for both)
if l_invoice_id is null
or not invoice_belongs_to_user(l_invoice_id, :G_USER_ID) then
refuse(403, apex_lang.message('INVOICE_DOWNLOAD_ERROR'));
end if;
-- 3: it's theirs, so it is fine to say why it isn't available yet
select max(status) into l_status from invoices where invoice_id = l_invoice_id;
if nvl(l_status, 'DRAFT') <> 'ISSUED' then
refuse(409, apex_lang.message('INVOICE_NOT_READY'));
end if;
-- fetch on the SERVER: the browser never sees this URL
l_pdf := apex_web_service.make_rest_request_b(
p_url => :REPORT_SERVER_URL || '/print?report=invoice&invoice_id=' || l_invoice_id,
p_http_method => 'GET');
if apex_web_service.g_status_code <> 200 or dbms_lob.getlength(l_pdf) = 0 then
refuse(502, apex_lang.message('INVOICE_DOWNLOAD_ERROR'));
end if;
-- stream the file
owa_util.mime_header('application/pdf', false);
htp.p('Content-Length: ' || dbms_lob.getlength(l_pdf));
htp.p('Content-Disposition: attachment; filename="invoice_' || l_invoice_id || '.pdf"');
owa_util.http_header_close;
wpg_docload.download_file(l_pdf);
apex_application.stop_apex_engine;
exception
when others then
if sqlcode = -20876 then raise; end if; -- stop_apex_engine's own signal: let it through
refuse(500, apex_lang.message('INVOICE_DOWNLOAD_ERROR'));
end;
A few details are doing more work than they look.
stop_apex_engine raises, so WHEN OTHERS must let -20876 through#
apex_application.stop_apex_engine tells APEX “the response is complete, don’t render anything else”. It does this by raising an exception, ORA-20876. A plain WHEN OTHERS catches that exception, treats a successful download as an error, and then tries to send a second response. The first line of the handler re-raises -20876 and handles everything else.
Without stop_apex_engine at all, APEX may append its own output after the PDF bytes, and the file arrives corrupted.
Refuse with one generic message where it matters#
“Not logged in”, “not a number” and “not your invoice” all return the same 403 and the same text. A different message for “exists but isn’t yours” would tell an attacker which ids are real. Only after ownership is proven does the process give a specific reason (409, “not ready yet”).
Check ownership with a fresh query#
invoice_belongs_to_user queries the database. It does not trust a hidden page item or anything else the browser sent, because anything the browser sends can be changed. The only input from the browser is the invoice id, and that id is exactly what the function checks.
Keep the server address out of the code#
:REPORT_SERVER_URL is an application substitution string. Each environment (dev, test, production) sets its own value, and the process never changes.
The JavaScript#
function invoiceUrl(invoiceId) {
return apex.server.url({
p_request: "APPLICATION_PROCESS=DOWNLOAD_INVOICE",
x01: invoiceId
});
}
async function downloadInvoice(invoiceId) {
try {
const res = await fetch(invoiceUrl(invoiceId), { credentials: "same-origin" });
const type = res.headers.get("Content-Type") || "";
if (!res.ok || !type.includes("pdf")) {
// the body is the refusal message sent by the process
apex.message.showErrors([{ type: "error", location: "page",
message: (await res.text()) || apex.lang.getMessage("INVOICE_DOWNLOAD_ERROR") }]);
return;
}
const blob = await res.blob();
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = `invoice_${invoiceId}.pdf`;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(a.href);
} catch (e) {
// the request never arrived (network down): still tell the user something
apex.message.showErrors([{ type: "error", location: "page",
message: apex.lang.getMessage("INVOICE_DOWNLOAD_ERROR") }]);
}
}
apex.server.urlbuilds a URL inside the current session, which is why the process can read:G_USER_ID.x01arrives in PL/SQL asapex_application.g_x01.credentials: "same-origin"sends the session cookie with the request.- Check before you save. Without the
res.ok/ content-type check, a refusal is saved to disk as a brokeninvoice.pdf. apex.lang.getMessageonly finds a text message whose Used in JavaScript option is on (Shared Components → Text Messages).
Two setup steps that are easy to miss#
1. Network access for apex_web_service#
apex_web_service calls go out from the database as the APEX engine schema (APEX_260100 on APEX 26.1), not as your application’s parsing schema. So the network ACL for the report server has to name that schema. Without it, the call fails with ORA-24247 (network access denied by access control list).
begin
dbms_network_acl_admin.append_host_ace(
host => 'reports.example.com',
ace => xs$ace_type(privilege_list => xs$name_list('http'),
principal_name => 'APEX_260100',
principal_type => xs_acl.ptype_db));
end;
/
2. The Ajax callback’s authorization scheme#
A new application process gets the authorization scheme Must Not Be Public User by default. In an authenticated application that’s what you want. In a public application (No Authentication plus its own login) every visitor is the public user, so the process never runs. The request comes back empty, and nothing in the page tells you why.
In that case, set the process’s authorization to No authorization required. That’s safe here only because the process performs its own identity and ownership checks, shown above.
What each request gets back#
| Request | Response | Tested |
|---|---|---|
| The owner, invoice issued | 200, the PDF | yes, over HTTP (a 238 KB PDF) |
| Nobody logged in | 403, the generic message | yes, over HTTP |
| Logged in, someone else’s invoice | 403, the same generic message | by design (same check as above) |
| The owner, not issued yet | 409, “not ready yet” | by design |
| Report server down | 502, the generic message | by design |
The two rows I tested are the ones that matter most: the file arrives for its owner, and nothing arrives for anyone else. The report server’s URL appears nowhere in the page source or in the requests the browser makes.
When to use this#
Use it for any file that belongs to a user: invoices, results, contracts, payslips. Whatever produces the file (JasperReports, BI Publisher, a REST service), the rule is the same: the browser asks APEX, APEX checks, APEX fetches.