diff --git a/README.md b/README.md
index b720e61..faa8ce4 100644
--- a/README.md
+++ b/README.md
@@ -555,6 +555,67 @@ pk.destroy! # Raises ApiKeys::Errors::KeyNotRevocableError
The dashboard UI automatically hides the revoke button for non-revocable keys.
+### Public Keys (Viewable Tokens)
+
+#### The Problem: Non-Revocable Key Lockout
+
+Non-revocable keys create a potential UX nightmare: if a user creates a publishable key, doesn't copy it immediately, and closes the page—they're locked out. The token is gone forever (we only store the hash), and they can't delete the key to create a new one (it's non-revocable). They're stuck with a useless key slot they can never use or remove.
+
+This is especially problematic when combined with `limit: 1`, which restricts users to a single publishable key per environment. A user who loses their token would be permanently locked out of creating publishable keys.
+
+#### The Solution: Storing Public Keys
+
+For publishable keys—which are *designed* to be embedded in client-side code and distributed apps—there's no security benefit to hiding the token. These keys are meant to be public! Stripe, for example, lets you view your publishable key anytime in the dashboard.
+
+The `public: true` option stores the plaintext token in metadata so users can view it again:
+
+```ruby
+config.key_types = {
+ publishable: {
+ prefix: "pk",
+ permissions: %w[read validate],
+ revocable: false,
+ public: true, # Store token for later viewing
+ limit: 1
+ },
+ secret: {
+ prefix: "sk",
+ permissions: :all
+ # public: false (default) - NEVER store secret keys!
+ }
+}
+```
+
+#### Security: Why This is Safe
+
+> [!IMPORTANT]
+> The `public` option only works when BOTH conditions are met:
+> - `public: true` is set in the key type configuration
+> - `revocable: false` is set (non-revocable keys only)
+
+This double-check is a deliberate safety measure:
+
+1. **Secret keys are NEVER stored** — Even if you accidentally set `public: true` on a secret key type, the gem checks for `revocable: false` as well. Secret keys are revocable by default, so they're protected.
+
+2. **Revocable keys are NEVER stored** — If a key can be revoked, users can always delete it and create a new one. There's no lockout risk, so no need to store the token.
+
+3. **Only truly public keys are stored** — Publishable keys with limited permissions, designed for client-side embedding, are the only keys that get stored. These tokens provide no security benefit when hidden—they're meant to be distributed.
+
+> [!WARNING]
+> ⚠️ **Never set `public: true` on secret keys or any key type with sensitive permissions.** The gem prevents this by requiring `revocable: false`, but you should also never configure it that way.
+
+When a key is public, the dashboard shows a "Show" button to reveal the full token:
+
+```ruby
+pk = user.create_api_key!(key_type: :publishable)
+pk.public_key_type? # => true
+pk.viewable_token # => "pk_test_abc123..." (the full token)
+
+sk = user.create_api_key!(key_type: :secret)
+sk.public_key_type? # => false
+sk.viewable_token # => nil (not stored)
+```
+
### Environment Isolation
With `strict_environment_isolation = true`, keys can only authenticate in their matching environment:
diff --git a/app/views/api_keys/keys/_key_row.html.erb b/app/views/api_keys/keys/_key_row.html.erb
index 249356f..60d518e 100644
--- a/app/views/api_keys/keys/_key_row.html.erb
+++ b/app/views/api_keys/keys/_key_row.html.erb
@@ -8,7 +8,7 @@
<% elsif key.expired? %>
[Expired]
<% end %>
- <%= key.name.presence || "Secret key" %>
+ <%= key.name.presence || (key.key_type.present? ? "#{key.key_type.humanize} key" : "API key") %>
<%# Key type and environment badges %>
<% if key.key_type.present? %>
<% type_config = key.key_type_config %>
@@ -22,7 +22,16 @@
<% end %>
-
<%= key.masked_token %>
+
+ <%= key.masked_token %>
+ <% if key.public_key_type? && key.viewable_token.present? %>
+ Show
+
+ <%= key.viewable_token %>
+ Copy
+
+ <% end %>
+
diff --git a/app/views/api_keys/keys/_keys_table.html.erb b/app/views/api_keys/keys/_keys_table.html.erb
index b675e39..683b0e4 100644
--- a/app/views/api_keys/keys/_keys_table.html.erb
+++ b/app/views/api_keys/keys/_keys_table.html.erb
@@ -12,7 +12,7 @@
Name
- Secret Key
+ API Key
Created
Expires
Last Used
diff --git a/app/views/api_keys/keys/_show_token.html.erb b/app/views/api_keys/keys/_show_token.html.erb
index c19a7fd..a2b40c0 100644
--- a/app/views/api_keys/keys/_show_token.html.erb
+++ b/app/views/api_keys/keys/_show_token.html.erb
@@ -3,7 +3,11 @@
Save your key
-Please save your secret key in a safe place since you won't be able to view it again . Keep it secure, as anyone with your API key can make requests on your behalf. If you do lose it, you'll need to generate a new one.
+<% if api_key.public_key_type? %>
+ Here's your <%= api_key.key_type.humanize.downcase %> key. This key is designed to be embedded in client-side applications. You can view it again anytime from your dashboard.
+<% else %>
+ Please save your API key in a safe place since you won't be able to view it again . Keep it secure, as anyone with your API key can make requests on your behalf. If you lose it, you'll need to generate a new one.
+<% end %>
<%= link_to api_keys.security_best_practices_path, class: "text-primary api-keys-align-center" do %>
diff --git a/app/views/api_keys/keys/index.html.erb b/app/views/api_keys/keys/index.html.erb
index 47384c8..b976ea4 100644
--- a/app/views/api_keys/keys/index.html.erb
+++ b/app/views/api_keys/keys/index.html.erb
@@ -7,7 +7,7 @@
<%= link_to new_key_path, class: "button primary api-keys-align-center", role: "button" do %>
-
Create new secret key
+
Create new API key
<% end %>
diff --git a/lib/api_keys/configuration.rb b/lib/api_keys/configuration.rb
index dd7656f..88a26aa 100644
--- a/lib/api_keys/configuration.rb
+++ b/lib/api_keys/configuration.rb
@@ -63,9 +63,12 @@ class Configuration
# - :permissions [Array, :all] Scope ceiling for this type
# - :revocable [Boolean] Whether keys can be revoked (default: true)
# - :limit [Integer, nil] Max keys per owner per environment (nil = unlimited)
+ # - :public [Boolean] If true AND revocable: false, store plaintext token in
+ # metadata so it can be viewed again in dashboard. Use ONLY for publishable
+ # keys that are designed to be embedded in distributed apps. (default: false)
# @example
# config.key_types = {
- # publishable: { prefix: "pk", permissions: %w[read], revocable: false, limit: 1 },
+ # publishable: { prefix: "pk", permissions: %w[read], revocable: false, public: true, limit: 1 },
# secret: { prefix: "sk", permissions: :all }
# }
#
diff --git a/lib/api_keys/models/api_key.rb b/lib/api_keys/models/api_key.rb
index c97b6b0..a40d36b 100644
--- a/lib/api_keys/models/api_key.rb
+++ b/lib/api_keys/models/api_key.rb
@@ -102,6 +102,24 @@ def environment_config
ApiKeys.configuration.environments&.dig(environment.to_sym)
end
+ # Returns true if this key type is configured as public AND non-revocable.
+ # Only these keys have their plaintext token stored in metadata for later viewing.
+ # This is used for publishable keys that are designed to be embedded in distributed apps.
+ def public_key_type?
+ return false if key_type.blank?
+ config = key_type_config
+ return false if config.nil?
+ config[:public] == true && config[:revocable] == false
+ end
+
+ # Returns the stored plaintext token for public, non-revocable keys.
+ # Returns nil for all other key types (the token is only available at creation time).
+ # @return [String, nil] The full plaintext token, or nil if not stored
+ def viewable_token
+ return nil unless public_key_type?
+ metadata&.dig("token")
+ end
+
# Override destroy to prevent destroying non-revocable keys
def destroy
raise ApiKeys::Errors::KeyNotRevocableError unless revocable?
@@ -237,6 +255,14 @@ def generate_token_and_digest
if ApiKeys.configuration.expire_after.present? && self.expires_at.nil?
self.expires_at = ApiKeys.configuration.expire_after.from_now
end
+
+ # Store plaintext token in metadata for public, non-revocable keys.
+ # This allows users to view the token again in the dashboard.
+ # SECURITY: Only do this for keys explicitly configured as public: true
+ # AND revocable: false (e.g., publishable keys for distributed apps).
+ if public_key_type?
+ self.metadata = (self.metadata || {}).merge("token" => @token)
+ end
end
# == Validation Helpers ==
diff --git a/lib/generators/api_keys/templates/initializer.rb b/lib/generators/api_keys/templates/initializer.rb
index 0b2892b..1ed8959 100644
--- a/lib/generators/api_keys/templates/initializer.rb
+++ b/lib/generators/api_keys/templates/initializer.rb
@@ -111,18 +111,24 @@
# - permissions: Scope ceiling - array of allowed scopes, or :all for unrestricted
# - revocable: Whether keys of this type can be revoked/deleted (default: true)
# - limit: Max keys of this type per owner per environment (nil = unlimited)
+ # - public: If true AND revocable: false, stores plaintext token in metadata
+ # so it can be viewed again in the dashboard. Use ONLY for publishable
+ # keys designed to be embedded in distributed apps. (default: false)
+ # SECURITY: NEVER set public: true on secret keys!
#
# config.key_types = {
# publishable: {
# prefix: "pk", # → pk_test_, pk_live_
# permissions: %w[read validate], # Can ONLY have these scopes
# revocable: false, # Cannot be revoked - protects deployed apps!
+ # public: true, # Store token for later viewing in dashboard
# limit: 1 # Only 1 publishable key per environment
# },
# secret: {
# prefix: "sk", # → sk_test_, sk_live_
# permissions: :all # No scope restrictions
# # revocable: true (default)
+ # # public: false (default) - NEVER store secret keys!
# # limit: nil (default = unlimited)
# }
# }
diff --git a/test/key_types_test.rb b/test/key_types_test.rb
index fb8d217..f93bfc0 100644
--- a/test/key_types_test.rb
+++ b/test/key_types_test.rb
@@ -765,6 +765,342 @@ class KeyTypesTest < ApiKeys::Test
assert_equal true, ApiKeys.configuration.dashboard_allow_cross_environment
end
+ # =============================================================================
+ # Public Key Token Storage Tests
+ # =============================================================================
+
+ test "public_key_type? returns true for public non-revocable keys" do
+ configure_key_types_with_public!
+
+ user = User.create!(name: "Public Key User")
+ key = user.create_api_key!(name: "Publishable Key", key_type: :publishable, environment: :test)
+
+ assert key.public_key_type?
+ end
+
+ test "public_key_type? returns false for secret keys" do
+ configure_key_types_with_public!
+
+ user = User.create!(name: "Secret Key User")
+ key = user.create_api_key!(name: "Secret Key", key_type: :secret, environment: :test)
+
+ refute key.public_key_type?
+ end
+
+ test "public_key_type? returns false for keys without key_type" do
+ # Legacy key without key types configured
+ user = User.create!(name: "Legacy User")
+ key = user.create_api_key!(name: "Legacy Key")
+
+ refute key.public_key_type?
+ end
+
+ test "public_key_type? returns false when public is true but revocable is true" do
+ ApiKeys.configure do |config|
+ config.key_types = {
+ weird: {
+ prefix: "wk",
+ permissions: %w[read],
+ revocable: true, # revocable
+ public: true # but public
+ }
+ }
+ config.environments = { test: { prefix_segment: "test" } }
+ config.current_environment = -> { :test }
+ end
+
+ user = User.create!(name: "Weird Key User")
+ key = user.create_api_key!(name: "Weird Key", key_type: :weird, environment: :test)
+
+ # Should be false because revocable is true
+ refute key.public_key_type?
+ end
+
+ test "viewable_token returns stored token for public keys" do
+ configure_key_types_with_public!
+
+ user = User.create!(name: "Viewable Token User")
+ key = user.create_api_key!(name: "Publishable Key", key_type: :publishable, environment: :test)
+
+ # Capture the token at creation time
+ original_token = key.token
+
+ # Reload and check viewable_token
+ key.reload
+ assert_equal original_token, key.viewable_token
+ end
+
+ test "viewable_token returns nil for secret keys" do
+ configure_key_types_with_public!
+
+ user = User.create!(name: "Secret Token User")
+ key = user.create_api_key!(name: "Secret Key", key_type: :secret, environment: :test)
+
+ key.reload
+ assert_nil key.viewable_token
+ end
+
+ test "viewable_token returns nil for legacy keys" do
+ user = User.create!(name: "Legacy Token User")
+ key = user.create_api_key!(name: "Legacy Key")
+
+ key.reload
+ assert_nil key.viewable_token
+ end
+
+ test "public key stores token in metadata" do
+ configure_key_types_with_public!
+
+ user = User.create!(name: "Metadata Token User")
+ key = user.create_api_key!(name: "Publishable Key", key_type: :publishable, environment: :test)
+
+ original_token = key.token
+ key.reload
+
+ # Token should be stored in metadata
+ assert_equal original_token, key.metadata["token"]
+ end
+
+ test "secret key does NOT store token in metadata" do
+ configure_key_types_with_public!
+
+ user = User.create!(name: "No Store User")
+ key = user.create_api_key!(name: "Secret Key", key_type: :secret, environment: :test)
+
+ key.reload
+
+ # Token should NOT be in metadata
+ assert_nil key.metadata["token"]
+ end
+
+ test "public key token can be retrieved after reload" do
+ configure_key_types_with_public!
+
+ user = User.create!(name: "Persistent Token User")
+ key = user.create_api_key!(name: "Publishable Key", key_type: :publishable, environment: :test)
+
+ original_token = key.token
+
+ # Simulate a fresh load from database
+ loaded_key = ApiKeys::ApiKey.find(key.id)
+
+ assert_equal original_token, loaded_key.viewable_token
+ assert loaded_key.viewable_token.start_with?("pk_test_")
+ end
+
+ test "public key without public config option does not store token" do
+ # Configure without public: true
+ configure_key_types_and_environments! # Uses the standard config without public option
+
+ user = User.create!(name: "No Public Config User")
+ key = user.create_api_key!(name: "Publishable Key", key_type: :publishable, environment: :test)
+
+ key.reload
+
+ # Token should NOT be stored since public: true is not set
+ assert_nil key.metadata["token"]
+ assert_nil key.viewable_token
+ end
+
+ # =============================================================================
+ # SECURITY: Secret Key Protection Tests
+ # These tests verify that secret keys are NEVER stored or revealed
+ # =============================================================================
+
+ test "SECURITY: secret key token is NEVER stored in metadata even with public config" do
+ configure_key_types_with_public!
+
+ user = User.create!(name: "Secret Safety User")
+ key = user.create_api_key!(name: "Secret Key", key_type: :secret, environment: :test)
+ original_token = key.token
+
+ key.reload
+
+ # CRITICAL: Token must NOT be in metadata
+ assert_nil key.metadata["token"], "SECRET KEY TOKEN WAS STORED IN METADATA - SECURITY VIOLATION!"
+
+ # CRITICAL: viewable_token must return nil
+ assert_nil key.viewable_token, "SECRET KEY TOKEN WAS REVEALED - SECURITY VIOLATION!"
+
+ # Verify the key still works (token was generated correctly)
+ assert original_token.start_with?("sk_test_")
+ end
+
+ test "SECURITY: secret key token is NEVER revealed via viewable_token" do
+ configure_key_types_with_public!
+
+ user = User.create!(name: "Viewable Safety User")
+ key = user.create_api_key!(name: "Secret Key", key_type: :secret, environment: :live)
+
+ # Even for live environment secret keys
+ key.reload
+ assert_nil key.viewable_token, "SECRET KEY TOKEN WAS REVEALED - SECURITY VIOLATION!"
+ end
+
+ test "SECURITY: revocable keys with public:true do NOT store token" do
+ # This is a critical edge case - someone might misconfigure public:true on a revocable key
+ ApiKeys.configure do |config|
+ config.key_types = {
+ misconfigured: {
+ prefix: "mc",
+ permissions: :all,
+ revocable: true, # Revocable!
+ public: true # But marked as public - should be ignored!
+ }
+ }
+ config.environments = { test: { prefix_segment: "test" } }
+ config.current_environment = -> { :test }
+ end
+
+ user = User.create!(name: "Misconfigured Key User")
+ key = user.create_api_key!(name: "Misconfigured Key", key_type: :misconfigured, environment: :test)
+
+ key.reload
+
+ # Even though public:true is set, revocable:true should prevent storage
+ assert_nil key.metadata["token"], "REVOCABLE KEY TOKEN WAS STORED - SECURITY VIOLATION!"
+ assert_nil key.viewable_token, "REVOCABLE KEY TOKEN WAS REVEALED - SECURITY VIOLATION!"
+ end
+
+ test "SECURITY: legacy keys without key_type NEVER store token" do
+ # Legacy keys (no key_types configured) should never store tokens
+ user = User.create!(name: "Legacy Safety User")
+ key = user.create_api_key!(name: "Legacy Key")
+
+ key.reload
+
+ assert_nil key.metadata["token"], "LEGACY KEY TOKEN WAS STORED - SECURITY VIOLATION!"
+ assert_nil key.viewable_token, "LEGACY KEY TOKEN WAS REVEALED - SECURITY VIOLATION!"
+ end
+
+ test "SECURITY: key with nil key_type NEVER stores token" do
+ configure_key_types_with_public!
+
+ # Create a key directly without going through create_api_key! to test edge case
+ user = User.create!(name: "Nil Type User")
+
+ # Use build to bypass some validations, simulating edge case
+ key = ApiKeys::ApiKey.new(
+ owner: user,
+ name: "Nil Type Key",
+ key_type: nil, # Explicitly nil
+ environment: nil
+ )
+ key.save!
+
+ key.reload
+
+ assert_nil key.metadata["token"], "NIL KEY_TYPE KEY TOKEN WAS STORED - SECURITY VIOLATION!"
+ assert_nil key.viewable_token, "NIL KEY_TYPE KEY TOKEN WAS REVEALED - SECURITY VIOLATION!"
+ end
+
+ test "SECURITY: key with empty string key_type NEVER stores token" do
+ user = User.create!(name: "Empty Type User")
+
+ key = ApiKeys::ApiKey.new(
+ owner: user,
+ name: "Empty Type Key",
+ key_type: "", # Empty string
+ environment: ""
+ )
+ key.save!
+
+ key.reload
+
+ assert_nil key.metadata["token"], "EMPTY KEY_TYPE KEY TOKEN WAS STORED - SECURITY VIOLATION!"
+ assert_nil key.viewable_token, "EMPTY KEY_TYPE KEY TOKEN WAS REVEALED - SECURITY VIOLATION!"
+ end
+
+ test "SECURITY: metadata does not contain token for ANY secret key type" do
+ # Configure multiple secret key types
+ ApiKeys.configure do |config|
+ config.key_types = {
+ publishable: { prefix: "pk", permissions: %w[read], revocable: false, public: true },
+ secret: { prefix: "sk", permissions: :all },
+ admin: { prefix: "ak", permissions: :all }, # Another full-access key
+ service: { prefix: "sv", permissions: :all } # Service account key
+ }
+ config.environments = { test: { prefix_segment: "test" } }
+ config.current_environment = -> { :test }
+ end
+
+ user = User.create!(name: "Multi Secret User")
+
+ # Create all non-public key types
+ secret_key = user.create_api_key!(name: "Secret", key_type: :secret, environment: :test)
+ admin_key = user.create_api_key!(name: "Admin", key_type: :admin, environment: :test)
+ service_key = user.create_api_key!(name: "Service", key_type: :service, environment: :test)
+
+ [secret_key, admin_key, service_key].each do |key|
+ key.reload
+ assert_nil key.metadata["token"], "#{key.key_type.upcase} KEY TOKEN WAS STORED - SECURITY VIOLATION!"
+ assert_nil key.viewable_token, "#{key.key_type.upcase} KEY TOKEN WAS REVEALED - SECURITY VIOLATION!"
+ refute key.public_key_type?, "#{key.key_type.upcase} KEY WAS MARKED AS PUBLIC - SECURITY VIOLATION!"
+ end
+ end
+
+ test "SECURITY: only publishable keys with public:true AND revocable:false store token" do
+ configure_key_types_with_public!
+
+ user = User.create!(name: "Only Public User")
+
+ # Create both key types
+ publishable = user.create_api_key!(name: "Publishable", key_type: :publishable, environment: :test)
+ secret = user.create_api_key!(name: "Secret", key_type: :secret, environment: :test)
+
+ publishable_token = publishable.token
+ secret_token = secret.token
+
+ publishable.reload
+ secret.reload
+
+ # Only publishable should have stored token
+ assert_equal publishable_token, publishable.metadata["token"], "Publishable key should store token"
+ assert_equal publishable_token, publishable.viewable_token, "Publishable key should reveal token"
+
+ # Secret must NOT have stored token
+ assert_nil secret.metadata["token"], "SECRET KEY TOKEN WAS STORED - SECURITY VIOLATION!"
+ assert_nil secret.viewable_token, "SECRET KEY TOKEN WAS REVEALED - SECURITY VIOLATION!"
+ end
+
+ test "SECURITY: public_key_type? requires BOTH public:true AND revocable:false" do
+ # Test all combinations
+ test_cases = [
+ { public: true, revocable: false, expected: true, desc: "public:true, revocable:false" },
+ { public: true, revocable: true, expected: false, desc: "public:true, revocable:true" },
+ { public: false, revocable: false, expected: false, desc: "public:false, revocable:false" },
+ { public: false, revocable: true, expected: false, desc: "public:false, revocable:true" },
+ { public: nil, revocable: false, expected: false, desc: "public:nil, revocable:false" },
+ { public: nil, revocable: true, expected: false, desc: "public:nil, revocable:true" },
+ ]
+
+ test_cases.each_with_index do |tc, idx|
+ ApiKeys.configure do |config|
+ config.key_types = {
+ testkey: {
+ prefix: "t#{idx}",
+ permissions: %w[read],
+ revocable: tc[:revocable],
+ public: tc[:public]
+ }.compact # Remove nil values
+ }
+ config.environments = { test: { prefix_segment: "test" } }
+ config.current_environment = -> { :test }
+ end
+
+ user = User.create!(name: "Combo Test User #{idx}")
+ key = user.create_api_key!(name: "Test Key", key_type: :testkey, environment: :test)
+
+ if tc[:expected]
+ assert key.public_key_type?, "Expected public_key_type? to be true for #{tc[:desc]}"
+ assert_not_nil key.metadata["token"], "Expected token to be stored for #{tc[:desc]}"
+ else
+ refute key.public_key_type?, "Expected public_key_type? to be false for #{tc[:desc]} - SECURITY VIOLATION!"
+ assert_nil key.metadata["token"], "Token should NOT be stored for #{tc[:desc]} - SECURITY VIOLATION!"
+ end
+ end
+ end
+
# =============================================================================
# Migration Required Error Tests
# =============================================================================
@@ -810,6 +1146,34 @@ def configure_key_types_and_environments!
end
end
+ def configure_key_types_with_public!
+ ApiKeys.configure do |config|
+ config.key_types = {
+ publishable: {
+ prefix: "pk",
+ permissions: %w[read validate],
+ revocable: false,
+ public: true, # Store plaintext token for viewing
+ limit: 1
+ },
+ secret: {
+ prefix: "sk",
+ permissions: :all
+ # revocable defaults to true
+ # public defaults to false (never store secret keys!)
+ }
+ }
+
+ config.environments = {
+ test: { prefix_segment: "test" },
+ live: { prefix_segment: "live" }
+ }
+
+ config.current_environment = -> { :test }
+ config.strict_environment_isolation = false
+ end
+ end
+
def authenticate_with_environment_check(token)
# Find the key first
api_key = find_key_by_token(token)