How to Add a Forgot Password Flow with a Reset Link in Oracle APEX

Build a forgot password flow with a hashed one-time token, an emailed link, a reset page, and tests for used and expired links.

A forgot password flow lets users get back into an application without asking an administrator. The user enters an email address and receives a link by email. The link opens a page where the user chooses a new password.

The link must be safe. In this guide it is random, it works once, it expires after 30 minutes, and only its hash is stored.

The page never tells a stranger whether an email address is registered. This guide uses a table of users as the example, and the same steps work for any custom user table. Every part was tested in a running application.

The guide Create Change Password Screen for Custom Authentication in Oracle Apex is for a user who is signed in and knows the old password. Here the user has forgotten it, and the email link is the proof. The user table follows the idea of How to Build a Sign-Up Page for New Users in Oracle APEX.

Step 1: Create the Tables

RESET_USERS holds the accounts. It keeps a random salt and the SHA-512 hash of the password and the salt, never the password itself. RESET_TOKENS holds one row for each reset link that was sent.

Example:

create table reset_users (
  user_id             number generated by default on null as identity,
  username            varchar2(30 char)  not null,
  email               varchar2(120 char) not null,
  password_salt       raw(16)            default on null sys_guid() not null,
  password_hash       varchar2(128 char) not null,
  password_changed_on timestamp with time zone default on null systimestamp not null,
  constraint reset_users_pk primary key (user_id),
  constraint reset_users_user_uk unique (username),
  constraint reset_users_email_uk unique (email),
  constraint reset_users_user_ck check (username = upper(username)),
  constraint reset_users_email_ck check (email = lower(email))
);

create table reset_tokens (
  token_id   number generated by default on null as identity,
  user_id    number                      not null,
  token_hash varchar2(64 char)           not null,
  created_on timestamp with time zone    default on null systimestamp not null,
  expires_on timestamp with time zone    default on null systimestamp + interval '30' minute not null,
  used_on    timestamp with time zone,
  constraint reset_tokens_pk primary key (token_id),
  constraint reset_tokens_hash_uk unique (token_hash),
  constraint reset_tokens_user_fk foreign key (user_id)
    references reset_users (user_id) on delete cascade,
  constraint reset_tokens_dates_ck check (expires_on > created_on)
);

create index reset_tokens_user_ix on reset_tokens (user_id);

select table_name, constraint_name, constraint_type
from   user_constraints
where  table_name in ('RESET_USERS', 'RESET_TOKENS')
and    constraint_name not like 'SYS%'
order  by table_name, constraint_type, constraint_name;

Output:

Table RESET_USERS created.

Table RESET_TOKENS created.

Index RESET_TOKENS_USER_IX created.

TABLE_NAME      CONSTRAINT_NAME          CONSTRAINT_TYPE
_______________ ________________________ __________________
RESET_TOKENS    RESET_TOKENS_DATES_CK    C
RESET_TOKENS    RESET_TOKENS_PK          P
RESET_TOKENS    RESET_TOKENS_USER_FK     R
RESET_TOKENS    RESET_TOKENS_HASH_UK     U
RESET_USERS     RESET_USERS_EMAIL_CK     C
RESET_USERS     RESET_USERS_USER_CK      C
RESET_USERS     RESET_USERS_PK           P
RESET_USERS     RESET_USERS_EMAIL_UK     U
RESET_USERS     RESET_USERS_USER_UK      U

9 rows selected.

A few details in the tables:

  • The user name is unique and stored in upper case. The email address is unique and stored in lower case. Check constraints keep both rules in the database.
  • TOKEN_HASH stores the SHA-256 hash of the token, not the token. If someone reads the table, the hashes cannot be used as links.
  • CREATED_ON, EXPIRES_ON, the salt and the password change time use DEFAULT ON NULL, so an insert never has to set them. The link is valid for 30 minutes by default.
  • USED_ON stays empty until the link is used. The foreign key deletes the tokens of a user who is deleted, and the index on USER_ID keeps the lookup of a user fast.

Step 2: Create the Package

One package holds the rules, so the pages and the SQL tests use the same code. NEW_TOKEN creates a link token and SEND_LINK mails it. TOKEN_USER tells which user a token belongs to, and RESET_PASSWORD changes the password. AUTHENTICATE is the function for a custom authentication scheme.

Example:

create or replace package password_reset as
  c_min_length   constant pls_integer := 10;
  c_valid_minutes constant pls_integer := 30;

  function  password_problem (p_username in varchar2, p_password in varchar2) return varchar2;
  procedure add_user (p_username in varchar2, p_email in varchar2, p_password in varchar2);
  function  authenticate (p_username in varchar2, p_password in varchar2) return boolean;
  function  new_token (p_email in varchar2) return varchar2;
  procedure send_link (p_email in varchar2, p_url_template in varchar2);
  function  token_user (p_token in varchar2) return varchar2;
  procedure reset_password (p_token in varchar2, p_password in varchar2);
end password_reset;
/
create or replace package body password_reset as

  function hash_password (p_salt in raw, p_password in varchar2) return varchar2 is
    l_hash varchar2(128 char);
  begin
    select standard_hash(utl_raw.cast_to_raw(p_password) || p_salt, 'SHA512')
    into   l_hash
    from   dual;
    return l_hash;
  end hash_password;

  function hash_token (p_token in varchar2) return varchar2 is
    l_hash varchar2(64 char);
  begin
    select standard_hash(p_token, 'SHA256') into l_hash from dual;
    return l_hash;
  end hash_token;

  function password_problem (p_username in varchar2, p_password in varchar2) return varchar2 is
  begin
    if p_password is null or length(p_password) < c_min_length then
      return 'Use at least ' || c_min_length || ' characters.';
    elsif not regexp_like(p_password, '[A-Z]') or not regexp_like(p_password, '[a-z]') or not regexp_like(p_password, '[0-9]') then
      return 'Use upper case letters, lower case letters and a digit.';
    elsif instr(upper(p_password), upper(p_username)) > 0 then
      return 'The password must not contain the user name.';
    end if;
    return null;
  end password_problem;

  procedure add_user (p_username in varchar2, p_email in varchar2, p_password in varchar2) is
    l_salt reset_users.password_salt%type := sys_guid();
    l_hash reset_users.password_hash%type := hash_password(l_salt, p_password);
  begin
    insert into reset_users (username, email, password_salt, password_hash)
    values (upper(p_username), lower(p_email), l_salt, l_hash);
  end add_user;

  function authenticate (p_username in varchar2, p_password in varchar2) return boolean is
    l_user reset_users%rowtype;
  begin
    select * into l_user
    from   reset_users
    where  username = upper(p_username);
    return l_user.password_hash = hash_password(l_user.password_salt, p_password);
  exception
    when no_data_found then
      return false;
  end authenticate;

  function new_token (p_email in varchar2) return varchar2 is
    l_user_id reset_users.user_id%type;
    l_token   varchar2(64 char);
    l_hash    reset_tokens.token_hash%type;
  begin
    select user_id into l_user_id
    from   reset_users
    where  email = lower(trim(p_email));

    update reset_tokens
    set    expires_on = systimestamp
    where  user_id = l_user_id
    and    used_on is null
    and    expires_on > systimestamp;

    l_token := lower(rawtohex(dbms_crypto.randombytes(32)));
    l_hash  := hash_token(l_token);

    insert into reset_tokens (user_id, token_hash, expires_on)
    values (l_user_id, l_hash, systimestamp + numtodsinterval(c_valid_minutes, 'MINUTE'));
    return l_token;
  exception
    when no_data_found then
      return null;
  end new_token;

  procedure send_link (p_email in varchar2, p_url_template in varchar2) is
    l_token varchar2(64 char) := new_token(p_email);
    l_url   varchar2(1000 char);
    l_body  clob;
  begin
    if l_token is null then
      return;
    end if;
    l_url := replace(p_url_template, 'TOKENVALUE', l_token);
    l_body := '<p>We received a request to reset your password.</p>'
           || '<p><a href="' || l_url || '">Choose a new password</a></p>'
           || '<p>The link works once and expires in ' || c_valid_minutes || ' minutes. '
           || 'If you did not ask for it, ignore this message.</p>';
    apex_mail.send(
      p_to        => lower(trim(p_email)),
      p_from      => 'no-reply@example.com',
      p_subj      => 'Reset your password',
      p_body      => to_clob('Open this link to choose a new password: ' || l_url),
      p_body_html => l_body);
  end send_link;

  function token_user (p_token in varchar2) return varchar2 is
    l_username reset_users.username%type;
    l_hash     reset_tokens.token_hash%type := hash_token(p_token);
  begin
    select u.username into l_username
    from   reset_tokens t
    join   reset_users  u on u.user_id = t.user_id
    where  t.token_hash = l_hash
    and    t.used_on is null
    and    t.expires_on > systimestamp;
    return l_username;
  exception
    when no_data_found then
      return null;
  end token_user;

  procedure reset_password (p_token in varchar2, p_password in varchar2) is
    l_token_id reset_tokens.token_id%type;
    l_user_id  reset_users.user_id%type;
    l_username reset_users.username%type;
    l_problem  varchar2(200 char);
    l_salt     reset_users.password_salt%type := sys_guid();
    l_hash     reset_tokens.token_hash%type := hash_token(p_token);
    l_pw_hash  reset_users.password_hash%type;
  begin
    begin
      select t.token_id, u.user_id, u.username
      into   l_token_id, l_user_id, l_username
      from   reset_tokens t
      join   reset_users  u on u.user_id = t.user_id
      where  t.token_hash = l_hash
      and    t.used_on is null
      and    t.expires_on > systimestamp
      for update of t.used_on;
    exception
      when no_data_found then
        raise_application_error(-20010, 'This link is not valid any more. Ask for a new one.');
    end;

    l_problem := password_problem(l_username, p_password);
    if l_problem is not null then
      raise_application_error(-20011, l_problem);
    end if;

    l_pw_hash := hash_password(l_salt, p_password);

    update reset_users
    set    password_salt       = l_salt,
           password_hash       = l_pw_hash,
           password_changed_on = systimestamp
    where  user_id = l_user_id;

    update reset_tokens
    set    used_on = systimestamp
    where  token_id = l_token_id;

    update reset_tokens
    set    expires_on = systimestamp
    where  user_id = l_user_id
    and    used_on is null
    and    expires_on > systimestamp;
  end reset_password;

end password_reset;
/
show errors

Output:

Package PASSWORD_RESET compiled

Package Body PASSWORD_RESET compiled

No errors.

How the package works:

  • NEW_TOKEN makes 32 random bytes with DBMS_CRYPTO, shows them as 64 hex characters and stores only the hash. A new request ends every link of that user that is still open, so only the newest link works.
  • For an unknown email address NEW_TOKEN returns NULL and SEND_LINK sends nothing and raises no error. The page cannot be used to find out which addresses are registered.
  • TOKEN_USER returns a user name only for a token that is unused and not expired.
  • RESET_PASSWORD locks the token row with FOR UPDATE, so two requests with the same link cannot both succeed. It checks the password rules, saves a new salt and hash, marks the token as used and ends any other open link of the user.
  • The URL is passed in as a template with the word TOKENVALUE, because the package does not know the address of the application.

The password rules are the same as in the sign-up guide: at least 10 characters, upper case, lower case, a digit, and no user name inside the password. For a real application, use a slow hashing method or an identity provider.

Step 3: Add Two Users

Two users are added by SQL. Replace the placeholders with your own test passwords:

Example:

begin
  password_reset.add_user('RESET_ANNA', 'anna@example.com', '<password for RESET_ANNA>');
  password_reset.add_user('RESET_BEN', 'ben@example.com', '<password for RESET_BEN>');
  commit;
end;
/

select user_id, username, email
from   reset_users
order  by user_id;

Output:

PL/SQL procedure successfully completed.

   USER_ID USERNAME      EMAIL
__________ _____________ ___________________
        43 RESET_ANNA    anna@example.com
        44 RESET_BEN     ben@example.com

Step 4: Test the Package in SQL

Before any page exists, the package can be tested. The script sets the workspace, asks for links, tries bad and good passwords and then uses a link a second time. Another link is made old by moving its expiry time into the past. Each line shows what the call returned:

Example:

set serveroutput on
declare
  l_token  varchar2(64 char);
  l_token2 varchar2(64 char);

  procedure show (p_label in varchar2, p_value in varchar2) is
  begin
    dbms_output.put_line(rpad(p_label, 44) || p_value);
  end show;

  function yn (p_value in boolean) return varchar2 is
  begin
    return case when p_value then 'TRUE' else 'FALSE' end;
  end yn;

  procedure try_reset (p_label in varchar2, p_token in varchar2, p_password in varchar2) is
  begin
    password_reset.reset_password(p_token, p_password);
    show(p_label, 'password changed');
  exception
    when others then
      show(p_label, sqlerrm);
  end try_reset;
begin
  apex_util.set_workspace('SOLUTIONS');

  l_token := password_reset.new_token('Anna@Example.com');
  show('Token for a known email', case when l_token is not null then 'created, ' || length(l_token) || ' characters' end);
  show('Token for an unknown email', nvl(password_reset.new_token('nobody@example.com'), 'NULL (nothing created)'));
  show('Owner of the token', password_reset.token_user(l_token));
  show('Owner of a wrong token', nvl(password_reset.token_user('abc'), 'NULL'));

  l_token2 := password_reset.new_token('anna@example.com');
  show('Old token after a second request', nvl(password_reset.token_user(l_token), 'NULL (replaced)'));
  show('New token', password_reset.token_user(l_token2));

  try_reset('Weak password (too short)', l_token2, 'Short1');
  try_reset('Password contains the user name', l_token2, 'Reset_anna-2026x');
  show('Token still valid after refused attempts', nvl(password_reset.token_user(l_token2), 'NULL'));

  try_reset('Good password', l_token2, '<password for RESET_NEW>');
  show('New password works?', yn(password_reset.authenticate('RESET_ANNA', '<password for RESET_NEW>')));
  show('Old password works?', yn(password_reset.authenticate('RESET_ANNA', '<password for RESET_ANNA>')));
  try_reset('Same link used a second time', l_token2, '<password for RESET_NEW>');
  show('Other user unchanged (Ben signs in)?', yn(password_reset.authenticate('RESET_BEN', '<password for RESET_BEN>')));

  l_token := password_reset.new_token('ben@example.com');
  update reset_tokens
  set    created_on = systimestamp - interval '2' hour,
         expires_on = systimestamp - interval '90' minute
  where  user_id = (select user_id from reset_users where username = 'RESET_BEN')
  and    used_on is null;
  show('Expired link, who owns it?', nvl(password_reset.token_user(l_token), 'NULL (expired)'));
  try_reset('Reset with an expired link', l_token, '<password for RESET_NEW>');
  commit;
end;
/

select u.username,
       to_char(t.created_on, 'HH24:MI:SS') as created,
       case when t.used_on is not null then 'USED'
            when t.expires_on <= systimestamp then 'EXPIRED'
            else 'OPEN' end as state,
       length(t.token_hash) as hash_length
from   reset_tokens t
join   reset_users u on u.user_id = t.user_id
order  by t.token_id;

Output:

Token for a known email                     created, 64 characters
Token for an unknown email                  NULL (nothing created)
Owner of the token                          RESET_ANNA
Owner of a wrong token                      NULL
Old token after a second request            NULL (replaced)
New token                                   RESET_ANNA
Weak password (too short)                   ORA-20011: Use at least 10 characters.
Password contains the user name             ORA-20011: The password must not contain the user name.
Token still valid after refused attempts    RESET_ANNA
Good password                               password changed
New password works?                         TRUE
Old password works?                         FALSE
Same link used a second time                ORA-20010: This link is not valid any more. Ask for a new one.
Other user unchanged (Ben signs in)?        TRUE
Expired link, who owns it?                  NULL (expired)
Reset with an expired link                  ORA-20010: This link is not valid any more. Ask for a new one.

PL/SQL procedure successfully completed.

USERNAME      CREATED     STATE         HASH_LENGTH
_____________ ___________ __________ ______________
RESET_ANNA    13:12:10    EXPIRED                64
RESET_ANNA    13:12:10    USED                   64
RESET_BEN     11:12:10    EXPIRED                64

The results match the rules:

  • A known email address got a token of 64 characters, in any letter case. An unknown address got nothing, and a wrong token belongs to nobody.
  • A second request replaced the first link.
  • Two bad passwords were refused, and the link stayed valid, so the user can try again.
  • The good password worked at once, and the old password did not.
  • The same link could not be used twice, and an expired link was refused. Ben, who did not change anything, could still sign in with his own password.

The test left its rows behind, so the tables were emptied and the two users were added again before the browser tests.

Step 5: Create the Forgot Password Page

In App Builder, click Create Page. Select Blank Page and click Next:

Create Page wizard with the Blank Page tile marked with a red box
Create a blank page.

Enter Page Number 1360 and Name Forgot Password. Turn off Use Breadcrumb and Use Navigation, because visitors reach this page from the sign-in page. Click Create Page:

Create Page wizard with page number 1360, name Forgot Password and the breadcrumb and navigation switches off marked with red boxes
The page number, name and navigation settings.

In Page Designer, select the page. Set Page Template to Login, the template of the sign-in page. Then, under Security, set Authentication to Page Is Public. A user who forgot the password has no session, so the page must not ask for a sign-in:

Page Designer with the Authentication property of page 1360 set to Page Is Public and marked with a red box
The page is public.

Create a Static Content region named Forgot Your Password? with the Login template in the Content Body slot. Put the sentence that explains the page into its HTML Code, for example: Enter the email address of your account. If it is registered, we send a link to choose a new password.

Create the item P1360_EMAIL of Type Text Field with the label Email Address and Maximum Length 120. Turn Value Required on and set the Template to Required - Floating:

Page Designer with the item P1360_EMAIL, its type Text Field and Value Required marked with red boxes
The email item is required.

Create a button named SEND_LINK with the label Send Reset Link in the Next slot of the region. Turn Hot on, so it is the main button.

Create a second button, BACK_TO_SIGN_IN with the label Back to Sign In. Its Action is Redirect to Page in this Application with Target page 9999, and Warn on Unsaved Changes is Do Not Check:

Page Designer with the button SEND_LINK and its label marked with red boxes
The button that sends the link.

Open the Processing tab and create a process named Send reset link. Set Type to Execute Code and use the code below. The code builds the address of page 1361 with the word TOKENVALUE in place of the token. Set the Success Message to the text below and the Server-side Condition When Button Pressed to SEND_LINK:

Example:

password_reset.send_link(
    p_email        => :P1360_EMAIL,
    p_url_template => regexp_substr(apex_util.host_url('SCRIPT'), '^https?://[^/]+')
                      || apex_page.get_url(
                           p_page      => 1361,
                           p_items     => 'P1361_TOKEN',
                           p_values    => 'TOKENVALUE',
                           p_plain_url => true,
                           p_session   => 0));

The success message is: If this email address is registered, a reset link was sent. The link works for 30 minutes. The same message appears for every address, so it tells nothing about the user table:

Page Designer with the process Send reset link, its PL/SQL code, success message and button condition marked with red boxes
The process runs only for the button SEND_LINK.

Under After Processing, create a branch to page 9999, the sign-in page, so the user sees the message there. Click Save.

Step 6: Create the Reset Password Page

Create a second blank page with Page Number 1361 and Name Reset Password. Turn off breadcrumb and navigation again:

Create Page wizard with page number 1361, name Reset Password and the breadcrumb and navigation switches off marked with red boxes
The second page.

Select the page in Page Designer. Set Page Template to Login. Under Security, set Authentication to Page Is Public. Set Page Access Protection to Unrestricted, because the link in the email carries the token and cannot carry a checksum for a session that does not exist yet:

Page Designer with Authentication Page Is Public and Page Access Protection Unrestricted for page 1361 marked with red boxes
A public page that accepts the token from the address.

Create the region Choose a New Password, a Static Content region with the Login template in the Content Body slot. Create three items in it:

  • P1361_TOKEN, Type Hidden. This item receives the token from the address.
  • P1361_PASSWORD, Type Password, label New Password, Maximum Length 100.
  • P1361_CONFIRM_PASSWORD, Type Password, label Confirm New Password, Maximum Length 100.

For the two password items, turn Value Required on and set the Template to Required - Floating. Set Session State Storage to Per Request (Memory Only), so passwords are never kept in the session.

For P1361_TOKEN also set the storage to Per Request. Turn Value Protected off, because the token comes from the address and not from the page. Session State Protection stays Unrestricted:

Page Designer with the hidden item P1361_TOKEN, Value Protected off and Storage Per Request marked with red boxes
The hidden item carries the token.

Create a button named SAVE_PASSWORD with the label Save New Password in the Next slot of this region, with Hot turned on. The region must appear only when the link is good. Under Server-side Condition, set Type to Expression, Language to PL/SQL and enter the expression below:

Example:

password_reset.token_user(:P1361_TOKEN) is not null
Page Designer with the Server-side Condition of the region Choose a New Password marked with a red box
The form shows only for a valid token.

Create a second Static Content region named This Link Is Not Valid with the Login template. In its HTML Code, write: The link was already used, it has expired, or it is not complete. Ask for a new link. Add a Hot button REQUEST_NEW_LINK with the label Ask for a New Link that redirects to page 1360.

The condition is the opposite expression:

Example:

password_reset.token_user(:P1361_TOKEN) is null
Page Designer with the Server-side Condition of the region This Link Is Not Valid marked with a red box
A used, expired or missing token shows this region.

The page needs two validations, which run only when the button is pressed. Open the Processing tab. The first, Passwords match, has Type Expression with the PL/SQL expression below. Its Error Message is The passwords do not match. The Associated Item is P1361_CONFIRM_PASSWORD, and When Button Pressed is SAVE_PASSWORD:

Example:

:P1361_PASSWORD = :P1361_CONFIRM_PASSWORD

The second, Password meets the rules, has Type Function Body (returning error text) with the code below. The Associated Item is P1361_PASSWORD, and When Button Pressed is SAVE_PASSWORD:

Example:

return password_reset.password_problem(password_reset.token_user(:P1361_TOKEN), :P1361_PASSWORD);
Page Designer with the validation Password meets the rules, its type, associated item and button condition marked with red boxes
The validation shows the text of the broken rule.

Create the process Save new password with Type Execute Code and the code below. Set the Success Message to Your password was changed. Sign in with the new password. Set the Error Message to #SQLERRM_TEXT# and When Button Pressed to SAVE_PASSWORD:

Example:

password_reset.reset_password(
    p_token    => :P1361_TOKEN,
    p_password => :P1361_PASSWORD);
Page Designer with the process Save new password, its code, error message and button condition marked with red boxes
The process uses the token again, so a link used at the same moment cannot succeed twice.

Under After Processing, create a branch to page 9999. Click Save. The validations already check the rules, and the package checks them again, so the database is protected even if someone posts the form without the page.

Step 7: Add a Link to the Sign-In Page

Open page 9999. In the region Login, create a button named FORGOT_PASSWORD with the label Forgot Password?. Put it in the Next slot with Sequence 60. Set Action to Redirect to Page in this Application with Target page 1360, and Warn on Unsaved Changes to Do Not Check:

Page Designer with the button FORGOT_PASSWORD, its label, action and target page marked with red boxes
The sign-in page links to the forgot password page.

The sign-in page now shows the new button under Create an Account:

Sign-in page with the Forgot Password button marked with a red box
The new button on the sign-in page.

Step 8: Test the Flow in a Browser

The tests ran in a browser without a session. A click on Forgot Password opens the public page. The email address was entered in capital letters, to test that it is matched in lower case:

Forgot Password page with the email field and the Send Reset Link button marked with red boxes
The email address and the button.

After the click, the sign-in page showed the message. The message is the same for every address:

Sign-in page with the success message about the reset link marked with a red box
The success message.

To test an unknown address, nobody@example.com was entered. The page showed exactly the same message, and no mail reached the test mail server:

Sign-in page with the same success message after an unknown email address, marked with a red box
An unknown address gets the same answer.

The mail arrived at a local test mail server, which catches every message so nothing reaches a real inbox. In your own system, the real SMTP settings go into the instance administration. The mail has the link, the validity time and a line for people who did not ask for it:

Test mail server showing the Reset your password message with the link marked with a red box
The mail with the reset link.

Anna asked for a link twice, so two mails arrived. The link of the first mail was opened and showed that it is not valid, because the second request replaced it:

Reset Password page showing the title This Link Is Not Valid and the button Ask for a New Link, marked with red boxes
The older link no longer works.

The link of the second mail opened the form with two empty password fields:

Reset Password page with the two password fields marked with red boxes
The form for a valid link.

First, the password short1 and a different text in the confirmation were sent. Both rules answered at once, each next to its own field:

Reset Password page with the two error messages marked with red boxes
Every broken rule is shown at once.

Then a good password was entered twice. The user came back to the sign-in page with the success message:

Sign-in page with the message that the password was changed, marked with a red box
The password was changed.

These results were checked next:

  • The same link, opened again, showed This Link Is Not Valid, because it was used.
  • A link with a changed token, and the page without any token, showed the same message.
  • A link that was made 90 minutes old in the table showed the same message. The script for it is below.

Example:

update reset_tokens
set    created_on = systimestamp - interval '2' hour,
       expires_on = systimestamp - interval '90' minute
where  user_id = (select user_id from reset_users where username = 'RESET_BEN')
and    used_on is null;
commit;
Reset Password page showing This Link Is Not Valid for a link that was used, marked with a red box
A used link is refused.
Reset Password page showing This Link Is Not Valid for an expired link, marked with a red box
An expired link is refused.

The tables confirm the result. The password of RESET_ANNA works, and the password of RESET_BEN does not, because Ben never used his link. Every token is stored as a hash of 64 characters, and no link is still open:

Example:

select u.username,
       case when password_reset.authenticate(u.username, '<password for RESET_NEW>') then 'TRUE' else 'FALSE' end as new_password_works,
       length(u.password_hash) as hash_length
from   reset_users u
order  by u.user_id;

select u.username,
       case when t.used_on is not null then 'USED'
            when t.expires_on <= systimestamp then 'EXPIRED'
            else 'OPEN' end as state,
       length(t.token_hash) as hash_length
from   reset_tokens t
join   reset_users u on u.user_id = t.user_id
order  by t.token_id;

Output:

USERNAME      NEW_PASSWORD_WORKS       HASH_LENGTH
_____________ _____________________ ______________
RESET_ANNA    TRUE                             128
RESET_BEN     FALSE                            128

USERNAME      STATE         HASH_LENGTH
_____________ __________ ______________
RESET_ANNA    EXPIRED                64
RESET_ANNA    USED                   64
RESET_BEN     EXPIRED                64

Things to Know

  • In a first test, the package did not compile. A private function cannot be called inside a SQL statement, so the hashes are now first put into variables. The overload of APEX_MAIL.SEND for the plain text body also needs TO_CLOB.
  • In a first test, the mail had an empty token. The text #TOKEN# in the template is a substitution string for APEX and was replaced by nothing, so the template uses the plain word TOKENVALUE. The address of the server and the path were also doubled, which the REGEXP_SUBSTR in the process fixes.
  • APEX_MAIL.SEND only puts the mail in a queue, and a background job sends it. In the tests, the queue was pushed by hand, so the mail arrived without waiting.
  • The sign-in itself uses a custom authentication scheme that calls password_reset.authenticate, as shown in the sign-up guide. In this test the function was tested in SQL, and the application kept its own scheme.
  • The page does not tell a visitor if an address exists, but a script can still measure how long the answer takes. Add a CAPTCHA or a limit for requests from one address before you put the page on the internet.
  • Delete old rows of RESET_TOKENS with a job. Rows that are expired or used are of no use.
  • Send the link only by email, never show it on the page, and keep the validity short.

Related Guides

Conclusion

A token table, a package and two public pages give you a forgot password flow that is safe. The link is random, works once and ends after 30 minutes. Only the hash of the token is stored, and every answer to a stranger is the same.

The tests showed that an old link, a used link, an expired link and a wrong token are all refused, and that a bad password does not use up the link. Keep the rules in the package, so the pages and the SQL tests never disagree.

Get more Vinish.dev tutorials on Google

See my articles more often when you search Google.

Vinish Kapoor
Vinish Kapoor

An Oracle ACE, author of four books on Oracle APEX, SQL and PL/SQL, and Oracle Forms, and a software developer building Oracle database applications since 2001.

guest

0 Comments
Oldest
Newest Most Voted